require "openssl" # PKCS#8 or SEC 1; "" instead of nil so an encrypted key fails instead of promptingprivate_key = OpenSSL::PKey.read(File.read(ARGV.fetch(0, "key.pem")), ENV.fetch("KEY_PASSPHRASE", "")) public_pem = private_key.public_to_pem # SPKI, "BEGIN PUBLIC KEY"File.write("pub.pem", public_pem)puts public_pem # The same public key as a JWK: the uncompressed point 0x04 ‖ x ‖ y, split and base64url-encodedpoint = OpenSSL::PKey.read(File.read("pub.pem")).public_key.to_octet_string(:uncompressed)x, y = point.byteslice(1, 32), point.byteslice(33, 32) # fixed width, leading zeros keptb64url = ->(bytes) { [bytes].pack("m0").tr("+/", "-_").delete("=") }puts({ kty: "EC", crv: "P-256", x: b64url.(x), y: b64url.(y) }.to_json) How it works
OpenSSL::PKey.readloadsBEGIN PRIVATE KEYandBEGIN EC PRIVATE KEYfiles alike; the run checks that both give the same public PEM.public_to_pemwrites the public half only, as SPKI with the curve OID;public_to_dergives the same structure as bytes.public_key.to_octet_string(:uncompressed)returns the 65-byte point0x04 ‖ x ‖ ywith both coordinates at full width, so slicing it gives correct JWK coordinates.pack("m0")followed bytranddelete("=")is base64url without padding, the encoding JWK members use.
Gotchas
to_pemon a private key exports the private key (as SEC 1), not the public one. Usepublic_to_pemwhenever the output is meant to be shared.- Going through
public_key.to_bn.to_s(2)orOpenSSL::BN#to_s(2)for single coordinates drops leading zero bytes; slicing the octet string, as above, keeps them. OpenSSL::PKey.read(pem)without a second argument prompts on the terminal for an encrypted key. Pass the passphrase, or""to fail fast.- A public key loaded from an environment variable often has literal
\nsequences instead of newlines and fails withCould not parse PKey; restore the line breaks first.