require "openssl" key = OpenSSL::PKey.generate_key("ML-DSA-65") # OpenSSL 3.5+ under the openssl gempublic_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 itselfputs "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 herecontext = { "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
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_pemwrites a 1974-byte SPKI.sign(nil, message)passes no digest — ML-DSA hashes the message itself — and returns 3309 bytes;verify(nil, signature, message)returnstrueorfalse.- A third argument passes OpenSSL signature parameters:
{ "context-string" => "invoice-v1" }for a context,{ "deterministic" => "1" }for deterministic signing. - 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)raisesEVP_DigestSignInit: invalid digest (Explicit digest not supported for ML-DSA operations). key.oidraisesOpenSSL::PKey::PKeyError: EVP_PKEY_idfor ML-DSA keys; identify them by theBEGIN PUBLIC KEYcontent or by trying the operation, not byoid.- Default signing is randomized: two signatures of the same message differ.