Skip to content
ecdsa.com

Sign a message · Ruby

How to sign a message with ECDSA in Ruby

To sign a message with ECDSA in Ruby, load the key with OpenSSL::PKey.read and call sign with a digest name — Ruby's bundled openssl library hashes the message and returns an ASN.1 DER signature that OpenSSL, Java and Python verify as-is. A second snippet turns it into the 64-byte r‖s form that JWS and WebCrypto expect.

Tested with openssl (Ruby gem) 4.0.2 (Ruby 4.0.7, OpenSSL 3.5.7) and OpenSSL 3.5.7 (openssl dgst cross-check).

sign.rb — SHA-256 with ECDSA, DER output
require "openssl"
# PKCS#8 or SEC 1. The passphrase defaults to "" rather than nil: with nil, an encrypted
# key makes OpenSSL ask for it on the terminal
private_key = OpenSSL::PKey.read(File.read("key.pem"), ENV.fetch("KEY_PASSPHRASE", ""))
message = File.binread("message.txt")
signature = private_key.sign("SHA256", message) # ASN.1 DER, fresh random nonce each call
File.binwrite("sig.der", signature)
puts "DER signature: #{signature.bytesize} bytes"
puts "base64: #{[signature].pack('m0')}" # strict base64, no line breaks
the same signature as raw r‖s for JWS and WebCrypto
# JWS and WebCrypto want r‖s: the two INTEGERs, each left-padded to 32 bytes (P-256)
r, s = OpenSSL::ASN1.decode(signature).value.map { |integer| integer.value.to_s(2).rjust(32, "\0") }
File.binwrite("sig.raw", r + s)
puts "raw signature: #{(r + s).bytesize} bytes"

How it works

  1. OpenSSL::PKey.read loads BEGIN PRIVATE KEY and BEGIN EC PRIVATE KEY files; the second argument is the passphrase of an encrypted key.
  2. sign("SHA256", message) hashes the message with SHA-256 and signs the digest; the result is DER, at most 72 bytes on P-256. The run verifies it with openssl dgst -verify.
  3. [signature].pack("m0") is strict base64 without line breaks — Base64.encode64 would insert a newline every 60 characters.
  4. For r‖s, OpenSSL::ASN1.decode splits the DER into its two INTEGERs; to_s(2) gives their big-endian bytes, and rjust(32, "\0") restores the fixed width.

Gotchas

  • OpenSSL::PKey.read(pem) — or read(pem, nil) — on an encrypted key prints Enter PEM pass phrase: and waits for the terminal. In a service started from a shell that hangs; without a terminal it fails with Could not parse PKey. Passing "" when no passphrase is configured fails immediately instead, as the run shows.
  • OpenSSL::BN#to_s(2) drops leading zero bytes. Without rjust, about one signature in 128 produces a 63-byte raw value that every JWS verifier rejects.
  • Every call returns different bytes: OpenSSL picks a random nonce per signature. Verify signatures in tests instead of comparing them with stored values.
  • The digest name selects the hash, not the curve: signing with "SHA256" on a P-384 key works but breaks the usual pairing (ES384 means P-384 with SHA-384), so other stacks may reject it.

Related recipes