OpenSSL is one of those tools that accompanies us all the time while still being ignored by almost everyone. It is powerful, but it is also complicated and sometimes confusing.

This guide demonstrates two different operations:

  1. Signing: prove that data was signed by the holder of a private key and that it has not been modified.
  2. Encryption: keep data confidential for a particular recipient and detect tampering.

These are not the same thing. A signature does not hide the data, and encryption does not by itself prove who created the data.

The commands below target OpenSSL 3.x and were tested with:

OpenSSL 3.6.3 9 Jun 2026

This article was substantially corrected in 2026. An earlier version contained an unsafe, hand-built ECDH and AES-CBC scheme. Do not design a new cryptographic file format by combining low-level commands yourself; use a standard container such as CMS.

A very short guide to the key types

“Elliptic-curve cryptography” is a family of techniques, not one operation that every EC key can perform.

Key type Main purpose
ECDSA key on P-256/P-384/P-521 Digital signatures
Ed25519 Digital signatures
X25519 Key agreement
Conventional EC key on P-256/P-384/P-521 ECDSA signatures and, where the protocol supports it, ECDH key agreement

Ed25519 and X25519 are distinct algorithms even though both belong to the Curve25519 family. Ed25519 signs; X25519 performs key agreement. X25519 does not directly encrypt a file, and Ed25519 does not perform key agreement.

An X.509 certificate is also not required for the mathematics of a signature. A private key can sign data and the corresponding public key can verify it. A certificate becomes useful when we need to bind a public key to an identity or when a container format, such as CMS, identifies recipients using certificates.

Signing data with ECDSA

For this example, we will use ECDSA on the NIST P-384 curve. OpenSSL also calls this curve secp384r1.

Generate a password-protected private key

openssl genpkey \
  -algorithm EC \
  -pkeyopt ec_paramgen_curve:P-384 \
  -pkeyopt ec_param_enc:named_curve \
  -aes-256-cbc \
  -out signing-private.pem

OpenSSL will ask for a passphrase. The -aes-256-cbc option here protects the private-key file on disk; it is not the algorithm used to sign the data.

A password-protected private key is helpful if the file or a backup is copied, but it does not replace appropriate file permissions, host security, or a hardware-backed key where one is available. Automated services may also need a different key-protection strategy because nobody is present to type a passphrase after every restart.

Derive the public key:

openssl pkey \
  -in signing-private.pem \
  -pubout \
  -out signing-public.pem

The public key may be distributed. The private key must remain private.

openssl ecparam -list_curves shows what a particular OpenSSL build recognizes, not a list of curves that are all sensible choices for a new system. For a simple modern example, choose an established curve such as P-256 or P-384 rather than picking an obscure entry from that list.

Create some data

Use any file you want. For a tiny example:

printf '%s\n' 'This message came from Frank.' > data

Sign it

openssl dgst \
  -sha384 \
  -sign signing-private.pem \
  -out data.sig \
  data

This hashes data with SHA-384 and creates an ECDSA signature using the private key. The signature is binary data.

Verify it

openssl dgst \
  -sha384 \
  -verify signing-public.pem \
  -signature data.sig \
  data

If the file and signature match the public key, OpenSSL prints:

Verified OK

Change even one byte of data and verification will fail.

This is a raw signature workflow. The data.sig file does not carry the signer’s identity, certificate chain, signing time, or even a self-describing record of every command-line choice. The verifier must already have an authentic copy of signing-public.pem and know that SHA-384 was used. For a self-contained, certificate-oriented signed message, CMS can also be used for signing, but that is beyond this short example.

Encrypting data for a recipient

Public-key cryptography is normally not used to encrypt a large file directly. A hybrid encryption format instead:

  1. generates a fresh symmetric content-encryption key;
  2. encrypts the data with that key;
  3. protects the content key for each recipient using public-key cryptography; and
  4. stores the algorithms, parameters, recipients, encrypted content, and authentication tag in a defined container.

The important part is the word format. It is easy to recognize the individual ingredients—ECDH, a KDF, AES, a nonce—and still connect them incorrectly. We will therefore let OpenSSL’s standard CMS (Cryptographic Message Syntax) implementation handle the construction.

Why not build it with openssl enc?

openssl enc is useful for some password-based, legacy-compatible workflows, but it does not support authenticated modes such as GCM or CCM. AES-CBC encryption alone is not authenticated: an attacker may modify a ciphertext without a reliable authentication tag detecting the change.

There is another easy mistake:

openssl rand -out key.bin 32

This generates 32 bytes, or 256 bits, of random data. The length accepted by openssl rand is measured in bytes, not bits.

However, this command:

openssl enc ... -pass file:./key.bin

does not mean “use key.bin directly as the raw AES key.” The -pass option supplies a passphrase source. enc then derives a key and IV from that passphrase, and a file-based passphrase is treated as text rather than as an arbitrary binary key blob.

In the CMS workflow below, we do not manually generate or pass around a raw AES key. OpenSSL generates the content key and records the necessary parameters in the CMS object.

Create a recipient key and certificate

CMS identifies a recipient using a certificate. For this local demonstration, create a new password-protected P-384 private key:

openssl genpkey \
  -algorithm EC \
  -pkeyopt ec_paramgen_curve:P-384 \
  -pkeyopt ec_param_enc:named_curve \
  -aes-256-cbc \
  -out recipient-private.pem

Create a self-signed certificate whose key is allowed to perform key agreement:

openssl req \
  -new \
  -x509 \
  -key recipient-private.pem \
  -out recipient-cert.pem \
  -days 365 \
  -subj "/CN=CMS Demo Recipient" \
  -addext "keyUsage = critical, keyAgreement"

The self-signed certificate is suitable for a local demonstration, but it does not magically prove the recipient’s real-world identity. Before encrypting anything important, authenticate the recipient’s certificate through a trusted channel or a PKI.

The sender needs only recipient-cert.pem. Only the recipient should possess recipient-private.pem.

Encrypt with CMS and AES-256-GCM

openssl cms \
  -encrypt \
  -binary \
  -in data \
  -out data.cms \
  -outform DER \
  -aes-256-gcm \
  -recip recipient-cert.pem \
  -keyopt ecdh_kdf_md:sha256

This produces a binary CMS AuthEnvelopedData object. AES-256-GCM provides confidentiality and authentication for the content. CMS handles the randomly generated content key, nonce, GCM authentication tag, EC key agreement, KDF parameters, and recipient information.

Unlike the old hand-built approach, the sender does not need the recipient’s private key. The recipient’s certificate contains the public key needed for encryption.

Decrypt as the recipient

openssl cms \
  -decrypt \
  -binary \
  -inform DER \
  -in data.cms \
  -recip recipient-cert.pem \
  -inkey recipient-private.pem \
  -out data.decrypted

Compare the result with the original:

cmp data data.decrypted

cmp prints nothing and exits successfully when the two files are identical.

If the authenticated CMS content is modified, decryption fails rather than returning trustworthy plaintext. Do not use partially produced output from any failed decryption command.

Signing and encryption answer different questions

It is worth repeating the distinction:

  • Signature: “Does this content match the signature made by the holder of this private key?”
  • Encryption: “Can only an intended recipient recover this content, and has the authenticated ciphertext been modified?”

CMS encryption does not prove who sent the message. Anyone who has the recipient’s public certificate can encrypt a message to that recipient. If you need both properties, sign and encrypt using a defined protocol and decide carefully which identities and metadata must be covered. Do not assume that “encrypted for me” also means “sent by a particular person.”

Where X25519, Ed25519, KDFs, and nonces fit

These concepts often appear next to one another:

  • Ed25519 signs and verifies.
  • X25519 allows two parties to derive the same shared secret from their own private key and the other party’s public key.
  • A KDF turns suitable secret material into one or more purpose-specific keys.
  • A nonce is a per-encryption value that must follow the uniqueness requirements of the AEAD algorithm.
  • AES-GCM or ChaCha20-Poly1305 encrypts and authenticates the actual data.

Those pieces are the building blocks of modern protocols, but the safe serialization rules, algorithm identifiers, key separation, nonce construction, authenticated metadata, failure behavior, and truncation protection are also part of the security design. That is why this guide uses CMS instead of inventing a new “X25519 + HKDF + AEAD” file format in shell commands.

Outro

The OpenSSL commands are less mysterious once signing and encryption are kept separate:

ECDSA private key + data  -> signature
ECDSA public key + data + signature -> verified or rejected

recipient certificate + data -> authenticated CMS ciphertext
recipient private key + CMS ciphertext -> plaintext or failure

For further details, see the official OpenSSL documentation for genpkey, pkey, dgst, and cms.