Skip to content

Add Hybrid Public Key Encryption (HPKE) API Support#1061

Open
sylph01 wants to merge 45 commits into
ruby:masterfrom
sylph01:hpke
Open

Add Hybrid Public Key Encryption (HPKE) API Support#1061
sylph01 wants to merge 45 commits into
ruby:masterfrom
sylph01:hpke

Conversation

@sylph01

@sylph01 sylph01 commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

This patch introduces Hybrid Public Key Encryption (HPKE; RFC 9180) through OpenSSL's HPKE APIs ( https://docs.openssl.org/3.5/man3/OSSL_HPKE_CTX_new/ ), added in OpenSSL 3.2.0.

Usage

suite = OpenSSL::HPKE::Suite.new_with_names(:dhkem_x25519_hkdf_sha256, :hkdf_sha256, :aes_128_gcm)
pkey = OpenSSL::HPKE.keygen_with_suite(suite)
pub = pkey.raw_public_key
s = OpenSSL::HPKE::Context::Sender.new(:base, suite)
enc = s.encap(pub, "info")
ct = s.seal("aad", "hi")
r = OpenSSL::HPKE::Context::Receiver.new(:base, suite)
r.decap(enc, pkey, "info")
puts "roundtrip: #{r.open("aad", ct) == "hi"}, export match: #{s.export(32,"l")==r.export(32,"l")}"

APIs

OpenSSL::HPKE::Suite

  • new: Instantiate cipher suite with KEM, KDF, and AEAD identifiers listed in RFC 9180
  • new_with_names: Instantiate cipher suite with pre-defined names. Uses the list of KEMs, KDFs, AEADs listed in RFC 9180.

OpenSSL::HPKE

  • keygen: Generate OpenSSL::PKey private key with the specified KEM, KDF, and AEAD ID.
    • This exposes OpenSSL's OSSL_HPKE_keygen() API.
  • keygen_with_suite: Generate OpenSSL::PKey private key with the specified cipher suite

These are more like utility functions so if they look extraneous they can be removed in favor of using OpenSSL::PKey to generate corresponding keys.

OpenSSL::HPKE::Context::Sender and OpenSSL::HPKE::Context::Receiver

  • new: Instantiate HPKE Context.
    • Currently supports :base mode only; I wanted to let the maintainers see this pull request before adding :auth, :psk, and :auth_psk modes

OpenSSL::HPKE::Context::Sender

  • encap: Encapsulates key into the specified public key. Takes receiver's public key and info (application context information)
  • seal: Using the encapsulated key, seal message into ciphertext. Takes aad (additional authenticated data) and ciphertext itself.

OpenSSL::HPKE::Context::Receiver

  • decap: Decapsulates the key using the private key. Takes the encapsulation, private key, and info (application context information).
  • open: Using the decapsulated key, decrypt the ciphertext. Takes aad and ciphertext.

Availability

  • This functionality is available on OpenSSL newer than 3.2.0 without FIPS mode.
    • As OpenSSL's FIPS mode does not implement EC KEMs, HPKE on OpenSSL is unavailable even for curves that are supported by FIPS.
  • LibreSSL, AWS-LC is not supported.
    • As far as I know, they do not have the corresponding APIs.

sylph01 added 30 commits June 5, 2026 09:18
from now on this needs OpenSSL 3.2 to compile
works only with hpke.h that exposes OSSL_HPKE_CTX
The current longest possible public key size is 133 bytes, according to RFC 9180 section 7.1
- HPKE::Context.new that takes mode, role, and suite
- HPKE::Context now keeps track of which KEM/KDF/AEAD it uses under instance variable
- HPKE.keygen_with_suite
In this patch I also moved the `attr_reader` definitions of kem/kdf/aead_ids into C code
I am very iffy about this. Is there a safer way to handle this allocation?
The last version was not working....

- Sender and Receiver contexts get different classes
- Sender gets only sender APIs, Receiver gets only receiver APIs
- Sender and Receiver need Suite to initialize
- Removed old Context initialization API
@rhenium

rhenium commented Jun 8, 2026

Copy link
Copy Markdown
Member

Thanks for working on this!

Regarding the Ruby API:

OpenSSL::HPKE::Suite

* `new`: Instantiate cipher suite with KEM, KDF, and AEAD identifiers listed in RFC 9180

* `new_with_names`: Instantiate cipher suite with pre-defined names. Uses the list of KEMs, KDFs, AEADs listed in RFC 9180.

Do you think if it makes sense to have this as an overload to .new instead of a separate method?

I'm ambivalent about maintaining our own name list in ruby/openssl. I wonder if we could use OSSL_HPKE_str2suite() (https://docs.openssl.org/master/man3/OSSL_HPKE_CTX_new/#protocol-convenience-functions).

OpenSSL::HPKE

* `keygen`: Generate `OpenSSL::PKey` private key with the specified KEM, KDF, and AEAD ID.
  
  * This exposes OpenSSL's `OSSL_HPKE_keygen()` API.

* `keygen_with_suite`: Generate `OpenSSL::PKey` private key with the specified cipher suite

These are more like utility functions so if they look extraneous they can be removed in favor of using OpenSSL::PKey to generate corresponding keys.

This seems useful to me, since there is no straightforward way to map from HPKE KEM IDs to OpenSSL algorithm object names.

However I'm less sure about .keygen method taking a triple of IDs. Since it would most likely be used together with OpenSSL::HPKE::Context::{Sender,Receiver}.new, which requires an instance of Suite anyway, perhaps the variant taking a Suite would be sufficient?

OpenSSL::HPKE::Context::Sender and OpenSSL::HPKE::Context::Receiver

* `new`: Instantiate HPKE Context.
  
  * Currently supports `:base` mode only; I wanted to let the maintainers see this pull request before adding `:auth`, `:psk`, and `:auth_psk` modes

That sounds like a good plan to me.

@rhenium rhenium left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For style, please add newlines at the end of files and break long lines.

Comment thread ext/openssl/ossl.h
#include "openssl_missing.h"
#ifdef HAVE_OPENSSL_HPKE_H
#include <openssl/hpke.h>
#endif

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be moved to ossl_hpke_ctx.c since it's the only file requiring this.

(We probably should do the same to pkcs12.h/pkcs7.h/ts.h/ocsp.h/etc.)

mode_table = rb_const_get_at(cContext, rb_intern("MODES"));
mode_id = rb_funcall(mode_table, rb_intern("[]"), 1, mode);

const char *propq = EVP_default_properties_is_fips_enabled(NULL) ? "fips=yes" : NULL;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EVP_default_properties_is_fips_enabled() == 1 enables "fips=yes" as the default query so that it doesn't have to be explicitly specified.

I think we should not need any FIPS-mode-specific handling in the extension.

Comment on lines +8 to +19
extern VALUE mHPKE;
extern VALUE cContext;
extern const rb_data_type_t ossl_hpke_ctx_type;

#if OSSL_OPENSSL_PREREQ(3, 2, 0)
#define GetHpkeCtx(obj, ctx) do {\
TypedData_Get_Struct((obj), OSSL_HPKE_CTX, &ossl_hpke_ctx_type, (ctx)); \
if (!(ctx)) { \
rb_raise(rb_eRuntimeError, "OSSL_HPKE_CTX wasn't initialized!");\
} \
} while (0)
#endif

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These can be local to the .c file.

Comment thread lib/openssl/hpke.rb
Comment on lines +8 to +18
class Context
# supports only base mode for now
MODES = {
base: 0x00
}.freeze

attr_reader :kem_id, :kdf_id, :aead_id
end

class Suite
attr_reader :kem_id, :kdf_id, :aead_id

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this class be moved to the extension? I'd prefer not to introduce circular dependency between openssl.so and openssl.rb.

static void
ossl_hpke_ctx_free(void *ptr)
{
#if OSSL_OPENSSL_PREREQ(3, 2, 0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If no useful feature can be provided, OpenSSL::HPKE should not be defined at all. Please take a look at ossl_ocsp.c as an example.

Also HAVE_OPENSSL_HPKE_H should be used instead of the version number, in case it is merged to LibreSSL or AWS-LC.

{
0, ossl_hpke_ctx_free,
},
0, 0, RUBY_TYPED_FREE_IMMEDIATELY

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
0, 0, RUBY_TYPED_FREE_IMMEDIATELY
0, 0, RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED,

StringValue(label);
labellen = RSTRING_LEN(label);

secret_obj = rb_str_new(0, NUM2INT(secretlen));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking, but NUM2INT(secretlen) can invoke Ruby code (secretlen.to_int) and potentially mutate label or return an unstable value.

Also, Ruby Strings use long as the length, so NUM2LONG() is probably better fit here, though it likely doesn't matter in practice. It also calls .to_int.

#else
EVP_PKEY *pkey;
VALUE pkey_obj;
unsigned char pub[133]; // as per RFC9180 section 7.1, the maximum size of Npk possible is 133

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list in RFC 9180 appears to be the initial content of the registry and I'd expect OpenSSL to implement new KEMs in the future, once published as an RFC: https://www.iana.org/assignments/hpke/hpke.xhtml

Is there any way to avoid hardcoding the maximum size?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants