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 terminalprivate_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 callFile.binwrite("sig.der", signature)puts "DER signature: #{signature.bytesize} bytes"puts "base64: #{[signature].pack('m0')}" # strict base64, no line breaks # 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
OpenSSL::PKey.readloadsBEGIN PRIVATE KEYandBEGIN EC PRIVATE KEYfiles; the second argument is the passphrase of an encrypted key.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 withopenssl dgst -verify.[signature].pack("m0")is strict base64 without line breaks —Base64.encode64would insert a newline every 60 characters.- For
r‖s,OpenSSL::ASN1.decodesplits the DER into its two INTEGERs;to_s(2)gives their big-endian bytes, andrjust(32, "\0")restores the fixed width.
Gotchas
OpenSSL::PKey.read(pem)— orread(pem, nil)— on an encrypted key printsEnter PEM pass phrase:and waits for the terminal. In a service started from a shell that hangs; without a terminal it fails withCould 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. Withoutrjust, 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.