diff --git a/ext/openssl/extconf.rb b/ext/openssl/extconf.rb index 1f3298094..9a643e165 100644 --- a/ext/openssl/extconf.rb +++ b/ext/openssl/extconf.rb @@ -166,6 +166,7 @@ def find_openssl_library # added in 3.2.0 have_func("SSL_get0_group_name(NULL)", ssl_h) +have_func("OSSL_HPKE_CTX_new", "openssl/hpke.h") # added in 3.4.0 have_func("TS_VERIFY_CTX_set0_certs(NULL, NULL)", ts_h) diff --git a/ext/openssl/ossl.c b/ext/openssl/ossl.c index 5716e6f10..53807dab1 100644 --- a/ext/openssl/ossl.c +++ b/ext/openssl/ossl.c @@ -1148,6 +1148,7 @@ Init_openssl(void) Init_ossl_digest(); Init_ossl_engine(); Init_ossl_hmac(); + Init_ossl_hpke_ctx(); Init_ossl_kdf(); Init_ossl_ns_spki(); Init_ossl_ocsp(); diff --git a/ext/openssl/ossl.h b/ext/openssl/ossl.h index 0b479a720..0dd7c816a 100644 --- a/ext/openssl/ossl.h +++ b/ext/openssl/ossl.h @@ -192,6 +192,7 @@ extern VALUE dOSSL; #include "ossl_digest.h" #include "ossl_engine.h" #include "ossl_hmac.h" +#include "ossl_hpke_ctx.h" #include "ossl_kdf.h" #include "ossl_ns_spki.h" #include "ossl_ocsp.h" diff --git a/ext/openssl/ossl_hpke_ctx.c b/ext/openssl/ossl_hpke_ctx.c new file mode 100644 index 000000000..31a9ddf32 --- /dev/null +++ b/ext/openssl/ossl_hpke_ctx.c @@ -0,0 +1,351 @@ +/* + * Ruby/OpenSSL Project + * Copyright (C) 2026 Ruby/OpenSSL Project Authors + */ +#include "ossl.h" + +#if defined(HAVE_OSSL_HPKE_CTX_NEW) + +#include + +#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) + +static VALUE mHPKE; +static VALUE cSuite; +static VALUE cContext; +static VALUE cSenderContext; +static VALUE cReceiverContext; +static VALUE eHPKEError; + +static void +ossl_hpke_ctx_free(void *ptr) +{ + OSSL_HPKE_CTX_free(ptr); +} + +static const rb_data_type_t ossl_hpke_ctx_type = { + "OpenSSL/HPKE_CTX", + { + 0, ossl_hpke_ctx_free, + }, + 0, 0, RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED, +}; + +static VALUE +ossl_hpke_ctx_new_sender(VALUE self, VALUE mode, VALUE suite) +{ + OSSL_HPKE_CTX *sctx; + VALUE kem_id, kdf_id, aead_id, mode_table, mode_id; + + if (RTYPEDDATA_DATA(self)) + ossl_raise(eHPKEError, "HPKE context is already initialized"); + + kem_id = rb_iv_get(suite, "@kem_id"); + kdf_id = rb_iv_get(suite, "@kdf_id"); + aead_id = rb_iv_get(suite, "@aead_id"); + + rb_iv_set(self, "@kem_id", kem_id); + rb_iv_set(self, "@kdf_id", kdf_id); + rb_iv_set(self, "@aead_id", aead_id); + + OSSL_HPKE_SUITE hpke_suite = { + NUM2INT(kem_id), NUM2INT(kdf_id), NUM2INT(aead_id) + }; + mode_table = rb_const_get_at(cContext, rb_intern("MODES")); + mode_id = rb_funcall(mode_table, rb_intern("[]"), 1, mode); + + if((sctx = OSSL_HPKE_CTX_new(NUM2INT(mode_id), hpke_suite, + OSSL_HPKE_ROLE_SENDER, NULL, NULL)) == NULL) { + ossl_raise(eHPKEError, "could not create ctx"); + } + + RTYPEDDATA_DATA(self) = sctx; + return self; +} + +static VALUE +ossl_hpke_ctx_new_receiver(VALUE self, VALUE mode, VALUE suite) +{ + OSSL_HPKE_CTX *rctx; + VALUE kem_id, kdf_id, aead_id, mode_table, mode_id; + + if (RTYPEDDATA_DATA(self)) + ossl_raise(eHPKEError, "HPKE context is already initialized"); + + kem_id = rb_iv_get(suite, "@kem_id"); + kdf_id = rb_iv_get(suite, "@kdf_id"); + aead_id = rb_iv_get(suite, "@aead_id"); + + rb_iv_set(self, "@kem_id", kem_id); + rb_iv_set(self, "@kdf_id", kdf_id); + rb_iv_set(self, "@aead_id", aead_id); + + OSSL_HPKE_SUITE hpke_suite = { + NUM2INT(kem_id), NUM2INT(kdf_id), NUM2INT(aead_id) + }; + mode_table = rb_const_get_at(cContext, rb_intern("MODES")); + mode_id = rb_funcall(mode_table, rb_intern("[]"), 1, mode); + + if((rctx = OSSL_HPKE_CTX_new(NUM2INT(mode_id), hpke_suite, + OSSL_HPKE_ROLE_RECEIVER, + NULL, NULL)) == NULL) { + ossl_raise(eHPKEError, "could not create ctx"); + } + + RTYPEDDATA_DATA(self) = rctx; + return self; +} + +static VALUE +ossl_hpke_encap(VALUE self, VALUE pub, VALUE info) +{ + VALUE enc_obj; + size_t enclen; + OSSL_HPKE_CTX *sctx; + size_t publen; + size_t infolen; + OSSL_HPKE_SUITE suite = { + NUM2INT(rb_iv_get(self, "@kem_id")), + NUM2INT(rb_iv_get(self, "@kdf_id")), + NUM2INT(rb_iv_get(self, "@aead_id")) + }; + + GetHpkeCtx(self, sctx); + + StringValue(pub); + StringValue(info); + publen = RSTRING_LEN(pub); + infolen = RSTRING_LEN(info); + + enclen = OSSL_HPKE_get_public_encap_size(suite); + enc_obj = rb_str_new(0, enclen); + + if (OSSL_HPKE_encap(sctx, (unsigned char *)RSTRING_PTR(enc_obj), &enclen, + (unsigned char *)RSTRING_PTR(pub), publen, + (unsigned char *)RSTRING_PTR(info), infolen) != 1) { + ossl_raise(eHPKEError, "could not encap"); + } + + rb_str_resize(enc_obj, enclen); + return enc_obj; +} + +static VALUE +ossl_hpke_seal(VALUE self, VALUE aad, VALUE pt) +{ + VALUE ct_obj; + OSSL_HPKE_CTX *sctx; + OSSL_HPKE_SUITE suite = { + NUM2INT(rb_iv_get(self, "@kem_id")), + NUM2INT(rb_iv_get(self, "@kdf_id")), + NUM2INT(rb_iv_get(self, "@aead_id")) + }; + size_t ctlen, aadlen, ptlen; + + StringValue(aad); + StringValue(pt); + aadlen = RSTRING_LEN(aad); + ptlen = RSTRING_LEN(pt); + ctlen = OSSL_HPKE_get_ciphertext_size(suite, ptlen); + + ct_obj = rb_str_new(0, ctlen); + + GetHpkeCtx(self, sctx); + + if (OSSL_HPKE_seal(sctx, (unsigned char *)RSTRING_PTR(ct_obj), &ctlen, + (unsigned char *)RSTRING_PTR(aad), aadlen, + (unsigned char *)RSTRING_PTR(pt), ptlen) != 1) { + ossl_raise(eHPKEError, "could not seal"); + } + + return ct_obj; +} + +static VALUE +ossl_hpke_decap(VALUE self, VALUE enc, VALUE priv, VALUE info) +{ + OSSL_HPKE_CTX *rctx; + EVP_PKEY *pkey; + size_t enclen; + size_t infolen; + + GetHpkeCtx(self, rctx); + GetPKey(priv, pkey); + + StringValue(enc); + StringValue(info); + enclen = RSTRING_LEN(enc); + infolen = RSTRING_LEN(info); + + if (OSSL_HPKE_decap(rctx, (unsigned char *)RSTRING_PTR(enc), enclen, pkey, + (unsigned char *)RSTRING_PTR(info), infolen) != 1) { + ossl_raise(eHPKEError, "could not decap"); + } + + return Qtrue; +} + +static VALUE +ossl_hpke_open(VALUE self, VALUE aad, VALUE ct) +{ + VALUE pt_obj; + OSSL_HPKE_CTX *rctx; + size_t ptlen, aadlen, ctlen; + + StringValue(aad); + StringValue(ct); + aadlen = RSTRING_LEN(aad); + ctlen = RSTRING_LEN(ct); + ptlen = ctlen; + + pt_obj = rb_str_new(0, ptlen); + + GetHpkeCtx(self, rctx); + + if (OSSL_HPKE_open(rctx, (unsigned char *)RSTRING_PTR(pt_obj), &ptlen, + (unsigned char *)RSTRING_PTR(aad), aadlen, + (unsigned char *)RSTRING_PTR(ct), ctlen) != 1) { + ossl_raise(eHPKEError, "could not open"); + } + + rb_str_resize(pt_obj, ptlen); + + return pt_obj; +} + +static VALUE +ossl_hpke_export(VALUE self, VALUE secretlen, VALUE label) +{ + VALUE secret_obj; + OSSL_HPKE_CTX *ctx; + size_t labellen; + int outlen = NUM2INT(secretlen); + + StringValue(label); + labellen = RSTRING_LEN(label); + + secret_obj = rb_str_new(0, outlen); + + GetHpkeCtx(self, ctx); + if (OSSL_HPKE_export(ctx, (unsigned char *)RSTRING_PTR(secret_obj), + outlen, (unsigned char *)RSTRING_PTR(label), + labellen) != 1) { + ossl_raise(eHPKEError, "could not export"); + } + + return secret_obj; +} + +/* Suite */ +static VALUE +ossl_hpke_suite_initialize(VALUE self, VALUE kem_name, VALUE kdf_name, + VALUE aead_name) +{ + OSSL_HPKE_SUITE suite; + VALUE str = rb_sprintf("%"PRIsVALUE",%"PRIsVALUE",%"PRIsVALUE, + kem_name, kdf_name, aead_name); + + if (OSSL_HPKE_str2suite(StringValueCStr(str), &suite) != 1) { + ossl_raise(eHPKEError, "unknown HPKE suite: %"PRIsVALUE, str); + } + + rb_iv_set(self, "@kem_id", INT2NUM(suite.kem_id)); + rb_iv_set(self, "@kdf_id", INT2NUM(suite.kdf_id)); + rb_iv_set(self, "@aead_id", INT2NUM(suite.aead_id)); + + return self; +} + +/* private */ +static VALUE +ossl_hpke_ctx_alloc(VALUE klass) +{ + return TypedData_Wrap_Struct(klass, &ossl_hpke_ctx_type, NULL); +} + +/* HPKE module method */ +static VALUE +ossl_hpke_keygen(VALUE self, VALUE suite) +{ + EVP_PKEY *pkey; + VALUE pkey_obj; + /* as per RFC9180 section 7.1, the maximum size of Npk possible is 133 */ + unsigned char pub[133]; + size_t publen; + + if (!rb_obj_is_kind_of(suite, cSuite)) + ossl_raise(eHPKEError, "invalid suite specified"); + + OSSL_HPKE_SUITE hpke_suite = { + NUM2INT(rb_iv_get(suite, "@kem_id")), + NUM2INT(rb_iv_get(suite, "@kdf_id")), + NUM2INT(rb_iv_get(suite, "@aead_id")) + }; + /* set to the maximum length first; OSSL_HPKE_keygen() shrinks it down */ + publen = 133; + + if(!OSSL_HPKE_keygen(hpke_suite, pub, &publen, &pkey, NULL, 0, NULL, NULL)){ + ossl_raise(eHPKEError, "could not keygen"); + } + + pkey_obj = ossl_pkey_wrap(pkey); + + return pkey_obj; +} + +void +Init_ossl_hpke_ctx(void) +{ + mHPKE = rb_define_module_under(mOSSL, "HPKE"); + cSuite = rb_define_class_under(mHPKE, "Suite", rb_cObject); + cContext = rb_define_class_under(mHPKE, "Context", rb_cObject); + cSenderContext = rb_define_class_under(cContext, "Sender", cContext); + cReceiverContext = rb_define_class_under(cContext, "Receiver", cContext); + eHPKEError = rb_define_class_under(mHPKE, "HPKEError", eOSSLError); + + /* Context::MODES */ + VALUE modes = rb_hash_new(); + rb_hash_aset(modes, ID2SYM(rb_intern("base")), INT2NUM(0x00)); + rb_define_const(cContext, "MODES", rb_obj_freeze(modes)); + + /* attr_accessor for Context */ + rb_attr(cContext, rb_intern("kem_id"), 1, 0, Qfalse); + rb_attr(cContext, rb_intern("kdf_id"), 1, 0, Qfalse); + rb_attr(cContext, rb_intern("aead_id"), 1, 0, Qfalse); + + rb_define_module_function(mHPKE, "keygen", ossl_hpke_keygen, 1); + + /* attr_reader for Suite */ + rb_attr(cSuite, rb_intern("kem_id"), 1, 0, Qfalse); + rb_attr(cSuite, rb_intern("kdf_id"), 1, 0, Qfalse); + rb_attr(cSuite, rb_intern("aead_id"), 1, 0, Qfalse); + + rb_define_method(cSuite, "initialize", ossl_hpke_suite_initialize, 3); + + rb_define_method(cSenderContext, "initialize", ossl_hpke_ctx_new_sender, 2); + rb_define_method(cSenderContext, "encap", ossl_hpke_encap, 2); + rb_define_method(cSenderContext, "seal", ossl_hpke_seal, 2); + + rb_define_method(cReceiverContext, "initialize", + ossl_hpke_ctx_new_receiver, 2); + rb_define_method(cReceiverContext, "decap", ossl_hpke_decap, 3); + rb_define_method(cReceiverContext, "open", ossl_hpke_open, 2); + + rb_define_method(cContext, "export", ossl_hpke_export, 2); + + rb_define_alloc_func(cContext, ossl_hpke_ctx_alloc); +} + +#else /* !defined(HAVE_OSSL_HPKE_CTX_NEW) */ + +void +Init_ossl_hpke_ctx(void) +{ +} + +#endif diff --git a/ext/openssl/ossl_hpke_ctx.h b/ext/openssl/ossl_hpke_ctx.h new file mode 100644 index 000000000..42964e3a1 --- /dev/null +++ b/ext/openssl/ossl_hpke_ctx.h @@ -0,0 +1,10 @@ +/* + * Ruby/OpenSSL Project + * Copyright (C) 2026 Ruby/OpenSSL Project Authors + */ +#if !defined(OSSL_HPKE_CTX_H) +#define OSSL_HPKE_CTX_H + +void Init_ossl_hpke_ctx(void); + +#endif \ No newline at end of file diff --git a/test/openssl/test_hpke.rb b/test/openssl/test_hpke.rb new file mode 100644 index 000000000..609c4402e --- /dev/null +++ b/test/openssl/test_hpke.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true +require_relative 'utils' + +if defined?(OpenSSL) + +class OpenSSL::TestHPKE < OpenSSL::TestCase + def setup + super + # OpenSSL's FIPS provider does not implement the DHKEM KEM encapsulation + # used by HPKE, so no HPKE operation can complete a round-trip under FIPS. + # The whole feature is therefore omitted in FIPS mode. + omit_on_fips + # OpenSSL::HPKE is only defined when the extension was built against + # OpenSSL >= 3.2.0 (LibreSSL and AWS-LC do not provide the HPKE API). + unless defined?(OpenSSL::HPKE) + omit "HPKE is not supported by this OpenSSL" + end + end + + def test_suite_new_with_names + suite = OpenSSL::HPKE::Suite.new("X25519", "hkdf-sha256", "aes-128-gcm") + assert_equal(0x0020, suite.kem_id) + assert_equal(0x0001, suite.kdf_id) + assert_equal(0x0001, suite.aead_id) + end + + def test_suite_names_are_case_insensitive + suite = OpenSSL::HPKE::Suite.new("x25519", "HKDF-SHA256", "AES-128-GCM") + assert_equal(0x0020, suite.kem_id) + assert_equal(0x0001, suite.kdf_id) + assert_equal(0x0001, suite.aead_id) + end + + def test_suite_new_unknown_name_raises + assert_raise(OpenSSL::HPKE::HPKEError) do + OpenSSL::HPKE::Suite.new("bogus", "hkdf-sha256", "aes-128-gcm") + end + assert_raise(OpenSSL::HPKE::HPKEError) do + OpenSSL::HPKE::Suite.new("X25519", "bogus", "aes-128-gcm") + end + assert_raise(OpenSSL::HPKE::HPKEError) do + OpenSSL::HPKE::Suite.new("X25519", "hkdf-sha256", "bogus") + end + end + + def test_keygen_returns_pkey + pkey = OpenSSL::HPKE.keygen(x25519_suite) + assert_kind_of(OpenSSL::PKey::PKey, pkey) + end + + def test_keygen_for_all_kems + ["P-256", "P-384", "P-521", "X25519", "X448"].each do |kem| + suite = OpenSSL::HPKE::Suite.new(kem, "hkdf-sha256", "aes-128-gcm") + assert_kind_of(OpenSSL::PKey::PKey, + OpenSSL::HPKE.keygen(suite), + "keygen failed for KEM #{kem}") + end + end + + def test_keygen_rejects_non_suite + assert_raise(OpenSSL::HPKE::HPKEError) do + OpenSSL::HPKE.keygen("not a suite") + end + end + + def test_base_mode_roundtrip_x25519 + assert_hpke_roundtrip(x25519_suite) + end + + def test_base_mode_roundtrip_x448 + assert_hpke_roundtrip( + OpenSSL::HPKE::Suite.new("X448", "hkdf-sha512", "aes-256-gcm")) + end + + def test_base_mode_roundtrip_p256 + assert_hpke_roundtrip( + OpenSSL::HPKE::Suite.new("P-256", "hkdf-sha256", "aes-128-gcm")) + end + + def test_base_mode_roundtrip_chacha20poly1305 + assert_hpke_roundtrip( + OpenSSL::HPKE::Suite.new("X25519", "hkdf-sha256", "chacha20-poly1305")) + end + + def test_seal_open_multiple_messages_in_order + sender, receiver = paired_contexts(x25519_suite) + messages = ["first", "second", "third"] + ciphertexts = messages.map { |m| sender.seal("aad", m) } + opened = ciphertexts.map { |c| receiver.open("aad", c) } + assert_equal(messages, opened) + end + + def test_open_fails_with_wrong_aad + sender, receiver = paired_contexts(x25519_suite) + ct = sender.seal("correct aad", "secret") + assert_raise(OpenSSL::HPKE::HPKEError) do + receiver.open("wrong aad", ct) + end + end + + def test_open_fails_on_tampered_ciphertext + sender, receiver = paired_contexts(x25519_suite) + ct = sender.seal("aad", "secret message") + tampered = ct.dup + tampered.setbyte(0, tampered.getbyte(0) ^ 0xff) + assert_raise(OpenSSL::HPKE::HPKEError) do + receiver.open("aad", tampered) + end + end + + def test_export_secret_agreement + sender, receiver = paired_contexts(x25519_suite) + sender_secret = sender.export(32, "context label") + receiver_secret = receiver.export(32, "context label") + assert_equal(32, sender_secret.bytesize) + assert_equal(sender_secret, receiver_secret) + end + + def test_export_different_labels_differ + sender, = paired_contexts(x25519_suite) + assert_not_equal(sender.export(32, "label a"), sender.export(32, "label b")) + end + + def test_export_only_suite + suite = OpenSSL::HPKE::Suite.new("X25519", "hkdf-sha256", "exporter") + sender, receiver = paired_contexts(suite) + assert_equal(sender.export(32, "label"), receiver.export(32, "label")) + # The export-only AEAD cannot seal or open. + assert_raise(OpenSSL::HPKE::HPKEError) { sender.seal("aad", "msg") } + end + + def test_context_cannot_be_reinitialized + suite = x25519_suite + sender = OpenSSL::HPKE::Context::Sender.new(:base, suite) + assert_raise(OpenSSL::HPKE::HPKEError) do + sender.send(:initialize, :base, suite) + end + + receiver = OpenSSL::HPKE::Context::Receiver.new(:base, suite) + assert_raise(OpenSSL::HPKE::HPKEError) do + receiver.send(:initialize, :base, suite) + end + end + + def test_string_arguments_are_required + suite = x25519_suite + pkey = OpenSSL::HPKE.keygen(suite) + sender = OpenSSL::HPKE::Context::Sender.new(:base, suite) + assert_raise(TypeError) { sender.encap(12345, "info") } + assert_raise(TypeError) { sender.encap(public_key_bytes(pkey), 12345) } + end + + private + + def x25519_suite + OpenSSL::HPKE::Suite.new("X25519", "hkdf-sha256", "aes-128-gcm") + end + + # The KEM public key passed to #encap is the recipient's public key in the + # KEM's wire encoding: the raw key for X25519/X448, the uncompressed point + # for the NIST curves. + def public_key_bytes(pkey) + if pkey.is_a?(OpenSSL::PKey::EC) + pkey.public_key.to_octet_string(:uncompressed) + else + pkey.raw_public_key + end + end + + # Returns an established [sender, receiver] pair sharing the same context. + def paired_contexts(suite, info: "shared info") + pkey = OpenSSL::HPKE.keygen(suite) + sender = OpenSSL::HPKE::Context::Sender.new(:base, suite) + enc = sender.encap(public_key_bytes(pkey), info) + receiver = OpenSSL::HPKE::Context::Receiver.new(:base, suite) + assert_equal(true, receiver.decap(enc, pkey, info)) + [sender, receiver] + end + + def assert_hpke_roundtrip(suite, info: "some info", aad: "some aad", + message: "hello hpke") + pkey = OpenSSL::HPKE.keygen(suite) + + sender = OpenSSL::HPKE::Context::Sender.new(:base, suite) + enc = sender.encap(public_key_bytes(pkey), info) + ct = sender.seal(aad, message) + + receiver = OpenSSL::HPKE::Context::Receiver.new(:base, suite) + assert_equal(true, receiver.decap(enc, pkey, info)) + assert_equal(message, receiver.open(aad, ct)) + end +end + +end