Skip to content
ecdsa.com

ML-DSA-65 sign/verify · Ruby

How to sign and verify with ML-DSA-65 in Ruby

To sign and verify with ML-DSA-65 in Ruby, use the openssl gem on top of OpenSSL 3.5: OpenSSL::PKey.generate_key("ML-DSA-65") creates the key, and sign(nil, message) / verify(nil, signature, message) do the rest. Options such as a context string or deterministic signing pass through as a hash, as this recipe shows together with a signature OpenSSL made.

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

sign.rb — key pair, signature, verification, context string
require "openssl"
key = OpenSSL::PKey.generate_key("ML-DSA-65") # OpenSSL 3.5+ under the openssl gem
public_key = OpenSSL::PKey.read(key.public_to_pem)
message = "hello, post-quantum world"
signature = key.sign(nil, message) # nil digest: ML-DSA hashes the message itself
puts "public key #{public_key.public_to_der.bytesize} bytes SPKI, signature #{signature.bytesize} bytes"
puts "verified: #{public_key.verify(nil, signature, message)}"
puts "tampered message: #{public_key.verify(nil, signature, message + '!')}"
# OpenSSL signature parameters pass through as a hash: a context string here
context = { "context-string" => "invoice-v1" }
invoice_signature = key.sign(nil, message, context)
puts "with the context: #{public_key.verify(nil, invoice_signature, message, context)}, " \
"without: #{public_key.verify(nil, invoice_signature, message)}"

How it works

  1. OpenSSL::PKey.generate_key("ML-DSA-65") works with the openssl gem 4.0 when Ruby is linked against OpenSSL 3.5 or later; public_to_pem writes a 1974-byte SPKI.
  2. sign(nil, message) passes no digest — ML-DSA hashes the message itself — and returns 3309 bytes; verify(nil, signature, message) returns true or false.
  3. A third argument passes OpenSSL signature parameters: { "context-string" => "invoice-v1" } for a context, { "deterministic" => "1" } for deterministic signing.
  4. The run verifies OpenSSL's signature, and deterministic signing with the shared seed file reproduces it byte for byte (SHA-256 9456b955…68a6).

Gotchas

  • Passing a digest fails: sign("SHA256", message) raises EVP_DigestSignInit: invalid digest (Explicit digest not supported for ML-DSA operations).
  • key.oid raises OpenSSL::PKey::PKeyError: EVP_PKEY_id for ML-DSA keys; identify them by the BEGIN PUBLIC KEY content or by trying the operation, not by oid.
  • Default signing is randomized: two signatures of the same message differ.

Related recipes