Crypto recipe
Encrypting to a public key
Encrypt a message to someone’s public key with a hybrid X25519 + HKDF + AES-256-GCM scheme.
Sometimes you need to encrypt *to* a recipient rather than to a shared password: a backup only one machine may read, a payload only the server can open, a file you hand to someone who was never online at the same time as you. Nobody does this by encrypting the whole message with the public key — public-key operations are slow and size-limited. Everyone uses **hybrid encryption**: do a key exchange to get a symmetric key, then encrypt the actual data with AEAD.
The recipe below is the portable one. The sender makes a throwaway (*ephemeral*) X25519 key pair, does X25519 against the recipient’s public key, runs the result through HKDF, and encrypts with AES-256-GCM. The wire format is ephemeral public key || nonce || ciphertext+tag; the recipient repeats the exchange with its own private key and gets the same AES key. Because the sender’s key is ephemeral and discarded, the message key cannot be recovered later even from the sender — the same forward-secrecy argument as an ephemeral TLS handshake.
This is a hand-rolled version of what libsodium calls a **sealed box** and what the IETF standardised as **HPKE** (RFC 9180). Use the real thing when your language has one — the code here is what to write when it does not, and what those primitives are doing underneath.
Get it right
- Always hybrid: X25519 for the key, AEAD for the data. Never encrypt bulk data with a public-key operation.
- Use a fresh ephemeral key pair per message — that is what makes the message key unrecoverable afterwards.
- Never use the raw X25519 output as the AES key; run it through HKDF first.
- Bind both public keys into the derivation (as salt or
info) so the key is unique to this sender/recipient pair. - This gives confidentiality, not authenticity: anyone can encrypt to a public key. Sign the message (Ed25519) if the recipient must know who sent it.
- Prefer a vetted implementation — libsodium’s sealed boxes, HPKE, or
age— over hand-assembling this.
Implementation
Setup Standard library (crypto/ecdh, Go 1.20+) plus go get golang.org/x/crypto/hkdf.
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"fmt"
"io"
"golang.org/x/crypto/hkdf"
)
var info = []byte("cryptoguides hybrid v1")
// Bind both public keys into the derivation, so the key belongs to this pair alone.
func deriveKey(shared, ephPub, recipPub []byte) []byte {
salt := append(append([]byte{}, ephPub...), recipPub...)
key := make([]byte, 32)
if _, err := io.ReadFull(hkdf.New(sha256.New, shared, salt, info), key); err != nil {
panic(err)
}
return key
}
func gcmFor(key []byte) cipher.AEAD {
block, err := aes.NewCipher(key)
if err != nil {
panic(err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
panic(err)
}
return aead
}
func seal(recipPub *ecdh.PublicKey, msg []byte) ([]byte, error) {
eph, err := ecdh.X25519().GenerateKey(rand.Reader) // throwaway, never stored
if err != nil {
return nil, err
}
shared, err := eph.ECDH(recipPub)
if err != nil {
return nil, err
}
aead := gcmFor(deriveKey(shared, eph.PublicKey().Bytes(), recipPub.Bytes()))
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
// Wire format: ephemeral public key || nonce || ciphertext+tag.
out := append(append([]byte{}, eph.PublicKey().Bytes()...), nonce...)
return aead.Seal(out, nonce, msg, nil), nil
}
func open(recip *ecdh.PrivateKey, box []byte) ([]byte, error) {
ephPub, err := ecdh.X25519().NewPublicKey(box[:32])
if err != nil {
return nil, err
}
shared, err := recip.ECDH(ephPub)
if err != nil {
return nil, err
}
aead := gcmFor(deriveKey(shared, box[:32], recip.PublicKey().Bytes()))
n := aead.NonceSize()
return aead.Open(nil, box[32:32+n], box[32+n:], nil) // errors if tampered with
}
func main() {
recipient, _ := ecdh.X25519().GenerateKey(rand.Reader) // long-term key
box, err := seal(recipient.PublicKey(), []byte("attack at dawn"))
if err != nil {
panic(err)
}
pt, err := open(recipient, box)
if err != nil {
panic(err)
}
fmt.Println(string(pt)) // attack at dawn
} Setup pip install cryptography.
import os
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
INFO = b"cryptoguides hybrid v1"
def derive_key(shared, eph_pub, recip_pub):
# Bind both public keys into the derivation, so the key belongs to this pair alone.
return HKDF(algorithm=hashes.SHA256(), length=32, salt=eph_pub + recip_pub, info=INFO).derive(shared)
def seal(recipient_public, message):
eph = X25519PrivateKey.generate() # throwaway, never stored
eph_pub = eph.public_key().public_bytes_raw()
key = derive_key(eph.exchange(recipient_public), eph_pub, recipient_public.public_bytes_raw())
nonce = os.urandom(12)
# Wire format: ephemeral public key || nonce || ciphertext+tag.
return eph_pub + nonce + AESGCM(key).encrypt(nonce, message, None)
def open_box(recipient_private, box):
eph_pub, nonce, ct = box[:32], box[32:44], box[44:]
shared = recipient_private.exchange(X25519PublicKey.from_public_bytes(eph_pub))
key = derive_key(shared, eph_pub, recipient_private.public_key().public_bytes_raw())
return AESGCM(key).decrypt(nonce, ct, None) # raises InvalidTag if tampered with
recipient = X25519PrivateKey.generate() # long-term key; publish recipient.public_key()
box = seal(recipient.public_key(), b"attack at dawn")
assert open_box(recipient, box) == b"attack at dawn" Setup Built in (node:crypto; diffieHellman since Node 13.9, hkdfSync since Node 15).
import {
createPublicKey, generateKeyPairSync, diffieHellman,
hkdfSync, randomBytes, createCipheriv, createDecipheriv,
} from 'node:crypto';
const INFO = Buffer.from('cryptoguides hybrid v1');
// X25519 keys move over the wire as 32 raw bytes; JWK is the shortest way in and out.
const rawPublic = (key) => Buffer.from(key.export({ format: 'jwk' }).x, 'base64url');
const importPublic = (raw) =>
createPublicKey({ key: { kty: 'OKP', crv: 'X25519', x: raw.toString('base64url') }, format: 'jwk' });
// Bind both public keys into the derivation, so the key belongs to this pair alone.
const deriveKey = (shared, ephPub, recipPub) =>
Buffer.from(hkdfSync('sha256', shared, Buffer.concat([ephPub, recipPub]), INFO, 32));
function seal(recipPub, message) {
const eph = generateKeyPairSync('x25519'); // throwaway, never stored
const ephPub = rawPublic(eph.publicKey);
const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: importPublic(recipPub) });
const key = deriveKey(shared, ephPub, recipPub);
const nonce = randomBytes(12);
const c = createCipheriv('aes-256-gcm', key, nonce);
const ct = Buffer.concat([c.update(message), c.final()]);
return Buffer.concat([ephPub, nonce, ct, c.getAuthTag()]); // the wire format
}
function open(recipPriv, recipPub, box) {
const ephPub = box.subarray(0, 32);
const nonce = box.subarray(32, 44);
const ct = box.subarray(44, box.length - 16);
const tag = box.subarray(box.length - 16);
const shared = diffieHellman({ privateKey: recipPriv, publicKey: importPublic(ephPub) });
const d = createDecipheriv('aes-256-gcm', deriveKey(shared, ephPub, recipPub), nonce);
d.setAuthTag(tag);
return Buffer.concat([d.update(ct), d.final()]); // throws if tampered with
}
const recipient = generateKeyPairSync('x25519'); // the recipient's long-term key
const recipientPub = rawPublic(recipient.publicKey); // published; the sender needs only this
const box = seal(recipientPub, Buffer.from('attack at dawn'));
console.log(open(recipient.privateKey, recipientPub, box).toString()); // attack at dawn Setup dotnet add package NSec.Cryptography for X25519 + HKDF; AesGcm is built in.
using System.Security.Cryptography;
using System.Text;
using NSec.Cryptography;
var x25519 = KeyAgreementAlgorithm.X25519;
var hkdf = KeyDerivationAlgorithm.HkdfSha256;
byte[] info = Encoding.UTF8.GetBytes("cryptoguides hybrid v1");
byte[] Seal(byte[] recipientPublic, byte[] message)
{
using Key eph = Key.Create(x25519); // throwaway, never stored
byte[] ephPub = eph.PublicKey.Export(KeyBlobFormat.RawPublicKey);
PublicKey recip = PublicKey.Import(x25519, recipientPublic, KeyBlobFormat.RawPublicKey);
using SharedSecret shared = x25519.Agree(eph, recip)
?? throw new CryptographicException("invalid public key");
// Bind both public keys into the derivation, so the key belongs to this pair alone.
byte[] key = hkdf.DeriveBytes(shared, [.. ephPub, .. recipientPublic], info, 32);
byte[] nonce = RandomNumberGenerator.GetBytes(12);
byte[] ct = new byte[message.Length];
byte[] tag = new byte[16];
using var aes = new AesGcm(key, tag.Length);
aes.Encrypt(nonce, message, ct, tag);
// Wire format: ephemeral public key || nonce || ciphertext || tag.
return [.. ephPub, .. nonce, .. ct, .. tag];
}
byte[] Open(Key recipient, byte[] recipientPublic, byte[] box)
{
byte[] ephPub = box[..32];
byte[] nonce = box[32..44];
byte[] ct = box[44..^16];
byte[] tag = box[^16..];
using SharedSecret shared = x25519.Agree(recipient, PublicKey.Import(x25519, ephPub, KeyBlobFormat.RawPublicKey))
?? throw new CryptographicException("invalid ephemeral key");
byte[] key = hkdf.DeriveBytes(shared, [.. ephPub, .. recipientPublic], info, 32);
byte[] pt = new byte[ct.Length];
using var aes = new AesGcm(key, tag.Length);
aes.Decrypt(nonce, ct, tag, pt); // throws CryptographicException if tampered with
return pt;
}
using Key recipient = Key.Create(x25519); // long-term key
byte[] recipientPub = recipient.PublicKey.Export(KeyBlobFormat.RawPublicKey);
byte[] boxed = Seal(recipientPub, Encoding.UTF8.GetBytes("attack at dawn"));
Console.WriteLine(Encoding.UTF8.GetString(Open(recipient, recipientPub, boxed))); // attack at dawn Setup libsodium 1.0.19+ (crypto_kdf_hkdf_sha256). For real code prefer crypto_box_seal, which is this recipe, vetted.
#include <sodium.h>
#include <cstring>
#include <string>
#include <vector>
static const char *INFO = "cryptoguides hybrid v1";
// Bind both public keys into the derivation, so the key belongs to this pair alone.
static void derive_key(unsigned char key[32], const unsigned char *shared,
const unsigned char eph_pk[32], const unsigned char recip_pk[32]) {
unsigned char salt[64];
std::memcpy(salt, eph_pk, 32);
std::memcpy(salt + 32, recip_pk, 32);
unsigned char prk[crypto_kdf_hkdf_sha256_KEYBYTES];
crypto_kdf_hkdf_sha256_extract(prk, salt, sizeof salt, shared, crypto_scalarmult_BYTES);
crypto_kdf_hkdf_sha256_expand(key, 32, INFO, std::strlen(INFO), prk);
sodium_memzero(prk, sizeof prk);
}
int main() {
if (sodium_init() < 0) return 1;
if (crypto_aead_aes256gcm_is_available() == 0) return 1; // needs AES-NI
// Recipient's long-term key pair; only recip_pk is published.
unsigned char recip_sk[crypto_scalarmult_SCALARBYTES], recip_pk[crypto_scalarmult_BYTES];
randombytes_buf(recip_sk, sizeof recip_sk);
crypto_scalarmult_base(recip_pk, recip_sk);
// --- sender: ephemeral key pair -> shared secret -> AES key
unsigned char eph_sk[crypto_scalarmult_SCALARBYTES], eph_pk[crypto_scalarmult_BYTES];
randombytes_buf(eph_sk, sizeof eph_sk);
crypto_scalarmult_base(eph_pk, eph_sk);
unsigned char shared[crypto_scalarmult_BYTES];
if (crypto_scalarmult(shared, eph_sk, recip_pk) != 0) return 1;
unsigned char key[32];
derive_key(key, shared, eph_pk, recip_pk);
sodium_memzero(eph_sk, sizeof eph_sk); // the ephemeral secret is done with
std::string msg = "attack at dawn";
unsigned char nonce[crypto_aead_aes256gcm_NPUBBYTES];
randombytes_buf(nonce, sizeof nonce);
std::vector<unsigned char> ct(msg.size() + crypto_aead_aes256gcm_ABYTES);
unsigned long long ct_len;
crypto_aead_aes256gcm_encrypt(ct.data(), &ct_len,
reinterpret_cast<const unsigned char *>(msg.data()), msg.size(),
nullptr, 0, nullptr, nonce, key);
// Wire format: eph_pk || nonce || ct.
// --- recipient: same exchange from the other side
unsigned char shared2[crypto_scalarmult_BYTES];
if (crypto_scalarmult(shared2, recip_sk, eph_pk) != 0) return 1;
unsigned char key2[32];
derive_key(key2, shared2, eph_pk, recip_pk);
std::vector<unsigned char> pt(ct_len);
unsigned long long pt_len;
if (crypto_aead_aes256gcm_decrypt(pt.data(), &pt_len, nullptr, ct.data(), ct_len,
nullptr, 0, nonce, key2) != 0) {
return 1; // tampered with
}
return std::string(pt.begin(), pt.begin() + pt_len) == msg ? 0 : 1;
} libsodium ships this exact construction as crypto_box_seal / crypto_box_seal_open (with XSalsa20-Poly1305 and a derived nonce). Use it unless you need the AES-GCM wire format.
Setup org.bouncycastle:bcprov-jdk18on (X25519 with raw 32-byte keys, plus HKDF); AES-GCM is built in.
import java.security.SecureRandom;
import java.util.Arrays;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.bouncycastle.crypto.agreement.X25519Agreement;
import org.bouncycastle.crypto.digests.SHA256Digest;
import org.bouncycastle.crypto.generators.HKDFBytesGenerator;
import org.bouncycastle.crypto.params.HKDFParameters;
import org.bouncycastle.crypto.params.X25519PrivateKeyParameters;
import org.bouncycastle.crypto.params.X25519PublicKeyParameters;
SecureRandom rng = new SecureRandom();
byte[] info = "cryptoguides hybrid v1".getBytes();
byte[] message = "attack at dawn".getBytes();
// Recipient's long-term key pair; only the public key is published.
X25519PrivateKeyParameters recipientPriv = new X25519PrivateKeyParameters(rng);
X25519PublicKeyParameters recipientPub = recipientPriv.generatePublicKey();
// --- sender: ephemeral key pair -> shared secret -> AES key
X25519PrivateKeyParameters ephPriv = new X25519PrivateKeyParameters(rng); // throwaway
X25519PublicKeyParameters ephPub = ephPriv.generatePublicKey();
X25519Agreement agreement = new X25519Agreement();
agreement.init(ephPriv);
byte[] shared = new byte[agreement.getAgreementSize()];
agreement.calculateAgreement(recipientPub, shared, 0);
// Bind both public keys into the derivation, so the key belongs to this pair alone.
byte[] salt = new byte[64];
System.arraycopy(ephPub.getEncoded(), 0, salt, 0, 32);
System.arraycopy(recipientPub.getEncoded(), 0, salt, 32, 32);
HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest());
hkdf.init(new HKDFParameters(shared, salt, info));
byte[] key = new byte[32];
hkdf.generateBytes(key, 0, key.length);
byte[] nonce = new byte[12];
rng.nextBytes(nonce);
Cipher enc = Cipher.getInstance("AES/GCM/NoPadding");
enc.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), new GCMParameterSpec(128, nonce));
byte[] ct = enc.doFinal(message);
// Wire format: ephPub.getEncoded() || nonce || ct (ciphertext+tag).
// --- recipient: same exchange from the other side
X25519Agreement peer = new X25519Agreement();
peer.init(recipientPriv);
byte[] shared2 = new byte[peer.getAgreementSize()];
peer.calculateAgreement(ephPub, shared2, 0);
HKDFBytesGenerator hkdf2 = new HKDFBytesGenerator(new SHA256Digest());
hkdf2.init(new HKDFParameters(shared2, salt, info));
byte[] key2 = new byte[32];
hkdf2.generateBytes(key2, 0, key2.length);
Cipher dec = Cipher.getInstance("AES/GCM/NoPadding");
dec.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key2, "AES"), new GCMParameterSpec(128, nonce));
byte[] pt = dec.doFinal(ct); // AEADBadTagException if tampered with
if (!Arrays.equals(pt, message)) throw new IllegalStateException("roundtrip failed"); The JDK's own KeyPairGenerator.getInstance("X25519") works too, but its keys carry X.509/PKCS#8 encodings; Bouncy Castle's parameter classes hand you the raw 32 bytes that go on the wire.
Setup Cargo.toml: x25519-dalek = { version = "2", features = ["static_secrets"] }, hkdf = "0.12", sha2 = "0.10", aes-gcm = "0.10", rand = "0.8".
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use hkdf::Hkdf;
use rand::rngs::OsRng;
use sha2::Sha256;
use x25519_dalek::{EphemeralSecret, PublicKey, StaticSecret};
const INFO: &[u8] = b"cryptoguides hybrid v1";
// Bind both public keys into the derivation, so the key belongs to this pair alone.
fn derive_key(shared: &[u8], eph_pub: &[u8; 32], recip_pub: &[u8; 32]) -> [u8; 32] {
let mut salt = [0u8; 64];
salt[..32].copy_from_slice(eph_pub);
salt[32..].copy_from_slice(recip_pub);
let hk = Hkdf::<Sha256>::new(Some(&salt), shared);
let mut key = [0u8; 32];
hk.expand(INFO, &mut key).expect("32 is a valid output length");
key
}
fn main() {
// Recipient's long-term key pair; only recipient_public is published.
let recipient_secret = StaticSecret::random_from_rng(OsRng);
let recipient_public = PublicKey::from(&recipient_secret);
// --- sender: ephemeral key pair -> shared secret -> AES key
let eph_secret = EphemeralSecret::random_from_rng(OsRng); // consumed below, never stored
let eph_public = PublicKey::from(&eph_secret);
let shared = eph_secret.diffie_hellman(&recipient_public);
let key = derive_key(shared.as_bytes(), eph_public.as_bytes(), recipient_public.as_bytes());
let nonce_bytes: [u8; 12] = rand::random();
let nonce = Nonce::from_slice(&nonce_bytes);
let ct = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key))
.encrypt(nonce, b"attack at dawn".as_ref())
.expect("encryption failure");
// Wire format: eph_public.as_bytes() || nonce_bytes || ct.
// --- recipient: same exchange from the other side
let shared2 = recipient_secret.diffie_hellman(&eph_public);
let key2 = derive_key(shared2.as_bytes(), eph_public.as_bytes(), recipient_public.as_bytes());
let pt = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key2))
.decrypt(nonce, ct.as_ref())
.expect("wrong key or tampered ciphertext");
assert_eq!(pt, b"attack at dawn");
}