From 6b80681cde3c2c1c5254ddcbfbade5ec9ca22d4b Mon Sep 17 00:00:00 2001 From: Ryo Kajiwara Date: Sun, 21 Jun 2026 19:58:47 +0900 Subject: [PATCH] Add Hybrid Public Key Encryption (HPKE) API Support --- ext/openssl/extconf.rb | 1 + ext/openssl/ossl.c | 1 + ext/openssl/ossl.h | 1 + ext/openssl/ossl_hpke.c | 548 ++++++++++++++++++++++++++++++++++++++ ext/openssl/ossl_hpke.h | 10 + test/openssl/test_hpke.rb | 223 ++++++++++++++++ 6 files changed, 784 insertions(+) create mode 100644 ext/openssl/ossl_hpke.c create mode 100644 ext/openssl/ossl_hpke.h create mode 100644 test/openssl/test_hpke.rb diff --git a/ext/openssl/extconf.rb b/ext/openssl/extconf.rb index 1f3298094..bd7a29694 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(0, (OSSL_HPKE_SUITE){0}, 0, NULL, NULL)", "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..c869bc3ae 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(); Init_ossl_kdf(); Init_ossl_ns_spki(); Init_ossl_ocsp(); diff --git a/ext/openssl/ossl.h b/ext/openssl/ossl.h index 0b479a720..a52a91fde 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.h" #include "ossl_kdf.h" #include "ossl_ns_spki.h" #include "ossl_ocsp.h" diff --git a/ext/openssl/ossl_hpke.c b/ext/openssl/ossl_hpke.c new file mode 100644 index 000000000..0c04fc050 --- /dev/null +++ b/ext/openssl/ossl_hpke.c @@ -0,0 +1,548 @@ +/* + * Ruby/OpenSSL Project + * Copyright (C) 2026 Ruby/OpenSSL Project Authors + */ +#include "ossl.h" + +#if defined(HAVE_OSSL_HPKE_CTX_NEW) + +#include + +typedef struct ossl_hpke_ctx_st { + OSSL_HPKE_CTX *ctx; + OSSL_HPKE_SUITE suite; +} ossl_hpke_ctx_t; + +#define GetHpke(obj, data) do {\ + TypedData_Get_Struct((obj), ossl_hpke_ctx_t, &ossl_hpke_ctx_type, (data)); \ + if (!(data)) { \ + rb_raise(rb_eRuntimeError, "OSSL_HPKE_CTX wasn't initialized!");\ + } \ +} while (0) + +/* Document-module: OpenSSL::HPKE + * + * Hybrid Public Key Encryption (HPKE) as defined in RFC 9180. HPKE encrypts + * messages to the holder of a public key by combining a Key Encapsulation + * Mechanism (KEM), a Key Derivation Function (KDF), and an AEAD scheme, which + * together form an OpenSSL::HPKE::Suite. + * + * The sender encapsulates a key to the recipient and seals messages through an + * OpenSSL::HPKE::Context::Sender; the recipient decapsulates that key and opens + * the messages through an OpenSSL::HPKE::Context::Receiver. Only HPKE base mode + * is currently supported. + * + * Availability depends on the underlying OpenSSL: the HPKE API was added in + * OpenSSL 3.2. + */ +static VALUE mHPKE; +/* + * Classes + */ +/* Document-class: OpenSSL::HPKE::Suite + * Value object that specifies the HPKE cipher suite. + */ +static VALUE cSuite; +/* Document-class: OpenSSL::HPKE::Context + * Abstract class for HPKE contexts to be used in subsequent HPKE operations. + * Depending on the actor in the protocol, either Sender or + * Receiver will be used. + */ +static VALUE cContext; +/* Document-class: OpenSSL::HPKE::Context::Sender + * The sender's side of an HPKE context. Encapsulates a key to the recipient + * with #encap and protects messages with #seal. + */ +static VALUE cSenderContext; +/* Document-class: OpenSSL::HPKE::Context::Receiver + * The recipient's side of an HPKE context. Decapsulates the sender's key with + * #decap and recovers messages with #open. + */ +static VALUE cReceiverContext; +/* Document-class: OpenSSL::HPKE::HPKEError + * Generic exception raised when an HPKE operation fails. + */ +static VALUE eHPKEError; + +static void +ossl_hpke_ctx_free(void *ptr) +{ + ossl_hpke_ctx_t *data = ptr; + + OSSL_HPKE_CTX_free(data->ctx); + ruby_xfree(data); +} + +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, +}; + +#define GetHpkeSuite(obj, suite) do {\ + TypedData_Get_Struct((obj), OSSL_HPKE_SUITE, &ossl_hpke_suite_type, (suite)); \ + if (!(suite)) { \ + rb_raise(rb_eRuntimeError, "OSSL_HPKE_SUITE wasn't initialized!");\ + } \ +} while (0) + +static void +ossl_hpke_suite_free(void *ptr) +{ + ruby_xfree(ptr); +} + +static size_t +ossl_hpke_suite_memsize(const void *ptr) +{ + return sizeof(OSSL_HPKE_SUITE); +} + +static const rb_data_type_t ossl_hpke_suite_type = { + "OpenSSL/HPKE_SUITE", + { + 0, ossl_hpke_suite_free, ossl_hpke_suite_memsize, + }, + 0, 0, RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED, +}; + +/* + * call-seq: + * new(suite) -> sender_context + * + * Takes a OpenSSL::HPKE::Suite to generate a Context for the sender. + * Currently assumes Base mode as the HPKE mode. + */ +static VALUE +ossl_hpke_ctx_new_sender(VALUE self, VALUE suite) +{ + ossl_hpke_ctx_t *data; + OSSL_HPKE_SUITE *suite_st; + + if (RTYPEDDATA_DATA(self)) + ossl_raise(eHPKEError, "HPKE context is already initialized"); + if (!rb_obj_is_kind_of(suite, cSuite)) + ossl_raise(eHPKEError, "invalid suite specified"); + GetHpkeSuite(suite, suite_st); + + data = ALLOC(ossl_hpke_ctx_t); + data->ctx = NULL; + data->suite = *suite_st; + + data->ctx = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, data->suite, + OSSL_HPKE_ROLE_SENDER, NULL, NULL); + if (data->ctx == NULL) { + ruby_xfree(data); + ossl_raise(eHPKEError, "could not create ctx"); + } + + RTYPEDDATA_DATA(self) = data; + return self; +} + +/* + * call-seq: + * new(suite) -> receiver_context + * + * Takes a OpenSSL::HPKE::Suite to generate a Context for the receiver. + * Currently assumes Base mode as the HPKE mode. + */ +static VALUE +ossl_hpke_ctx_new_receiver(VALUE self, VALUE suite) +{ + ossl_hpke_ctx_t *data; + OSSL_HPKE_SUITE *suite_st; + + if (RTYPEDDATA_DATA(self)) + ossl_raise(eHPKEError, "HPKE context is already initialized"); + if (!rb_obj_is_kind_of(suite, cSuite)) + ossl_raise(eHPKEError, "invalid suite specified"); + GetHpkeSuite(suite, suite_st); + + data = ALLOC(ossl_hpke_ctx_t); + data->ctx = NULL; + data->suite = *suite_st; + + data->ctx = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, data->suite, + OSSL_HPKE_ROLE_RECEIVER, NULL, NULL); + if (data->ctx == NULL) { + ruby_xfree(data); + ossl_raise(eHPKEError, "could not create ctx"); + } + + RTYPEDDATA_DATA(self) = data; + return self; +} + +/* + * call-seq: + * encap(pub, info) -> encapsulated_key + * + * Takes a public key (OpenSSL::PKey) of the receiver and +info+ string + * (application context information; value that separates the domain in which + * the key is used), and encapsulates a key to be used in subsequent operations. + * Returns the encapsulated key as a String, which is to be passed to the + * receiver of the following messages. + */ +static VALUE +ossl_hpke_encap(VALUE self, VALUE pub, VALUE info) +{ + VALUE enc_obj; + size_t enclen; + ossl_hpke_ctx_t *data; + size_t publen; + size_t infolen; + + GetHpke(self, data); + + StringValue(pub); + StringValue(info); + publen = RSTRING_LEN(pub); + infolen = RSTRING_LEN(info); + + enclen = OSSL_HPKE_get_public_encap_size(data->suite); + enc_obj = rb_str_new(0, enclen); + + if (OSSL_HPKE_encap(data->ctx, (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; +} + +/* + * call-seq: + * seal(aad, plaintext) -> sealed_message + * + * Seals (encrypts) the +plaintext+ using the +Context+'s AEAD. +aad+ is + * extra data authenticated with, but not encrypted into, the ciphertext, and + * must be supplied identically to Receiver#open. + */ +static VALUE +ossl_hpke_seal(VALUE self, VALUE aad, VALUE pt) +{ + VALUE ct_obj; + ossl_hpke_ctx_t *data; + size_t ctlen, aadlen, ptlen; + + GetHpke(self, data); + + StringValue(aad); + StringValue(pt); + aadlen = RSTRING_LEN(aad); + ptlen = RSTRING_LEN(pt); + ctlen = OSSL_HPKE_get_ciphertext_size(data->suite, ptlen); + + ct_obj = rb_str_new(0, ctlen); + + if (OSSL_HPKE_seal(data->ctx, (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; +} + +/* + * call-seq: + * decap(enc, priv, info) -> true + * + * Takes the encapsulated key +enc+ (a String produced by the sender's + * Sender#encap), the receiver's own private key (OpenSSL::PKey), and +info+ + * string (application context information; value that separates the domain in + * which the key is used), and decapsulates the key to be used in subsequent + * operations. The +info+ must be identical to the one given to Sender#encap. + * Returns +true+ on success. + */ +static VALUE +ossl_hpke_decap(VALUE self, VALUE enc, VALUE priv, VALUE info) +{ + ossl_hpke_ctx_t *data; + EVP_PKEY *pkey; + size_t enclen; + size_t infolen; + + GetHpke(self, data); + GetPKey(priv, pkey); + + StringValue(enc); + StringValue(info); + enclen = RSTRING_LEN(enc); + infolen = RSTRING_LEN(info); + + if (OSSL_HPKE_decap(data->ctx, (unsigned char *)RSTRING_PTR(enc), enclen, pkey, + (unsigned char *)RSTRING_PTR(info), infolen) != 1) { + ossl_raise(eHPKEError, "could not decap"); + } + + return Qtrue; +} + +/* + * call-seq: + * open(aad, ciphertext) -> plaintext + * + * Opens (decrypts) the +ciphertext+ using the +Context+'s AEAD and returns the + * recovered plaintext. +aad+ is extra data authenticated with, but not + * encrypted into, the ciphertext, and must be identical to the +aad+ supplied + * to Sender#seal, otherwise opening fails. + */ +static VALUE +ossl_hpke_open(VALUE self, VALUE aad, VALUE ct) +{ + VALUE pt_obj; + ossl_hpke_ctx_t *data; + 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); + + GetHpke(self, data); + + if (OSSL_HPKE_open(data->ctx, (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; +} + +/* + * call-seq: + * export(secretlen, label) -> secret + * + * Derives and returns a +secretlen+-byte exporter secret bound to +label+, + * as a String. Both parties obtain the same secret only after the shared + * context has been established: the sender via Sender#encap and the receiver + * via Receiver#decap. Different +label+ values yield independent secrets. + */ +static VALUE +ossl_hpke_export(VALUE self, VALUE secretlen, VALUE label) +{ + VALUE secret_obj; + ossl_hpke_ctx_t *data; + size_t labellen; + int outlen = NUM2INT(secretlen); + + StringValue(label); + labellen = RSTRING_LEN(label); + + secret_obj = rb_str_new(0, outlen); + + GetHpke(self, data); + if (OSSL_HPKE_export(data->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 uint16_t +ossl_hpke_suite_id(VALUE num, const char *label) +{ + long id = NUM2LONG(num); + + if (id < 0 || id > 0xFFFF) + ossl_raise(eHPKEError, "%s id out of range (0..0xFFFF): %ld", label, id); + + return (uint16_t)id; +} + +/* + * call-seq: + * OpenSSL::HPKE::Suite.new(kem, kdf, aead) -> suite + * + * +kem+, +kdf+, and +aead+ are either all algorithm name strings (resolved via + * OSSL_HPKE_str2suite) or all Integer IANA algorithm IDs (as carried on the + * wire by e.g. ECH). The suite is validated against the algorithms the linked + * OpenSSL supports before it can be used. + */ +static VALUE +ossl_hpke_suite_initialize(VALUE self, VALUE kem, VALUE kdf, VALUE aead) +{ + OSSL_HPKE_SUITE *suite, tmp; + + if (RTYPEDDATA_DATA(self)) + ossl_raise(eHPKEError, "HPKE suite is already initialized"); + + if (RB_INTEGER_TYPE_P(kem) && RB_INTEGER_TYPE_P(kdf) && + RB_INTEGER_TYPE_P(aead)) { + tmp.kem_id = ossl_hpke_suite_id(kem, "KEM"); + tmp.kdf_id = ossl_hpke_suite_id(kdf, "KDF"); + tmp.aead_id = ossl_hpke_suite_id(aead, "AEAD"); + + if (OSSL_HPKE_suite_check(tmp) != 1) { + ossl_raise(eHPKEError, "unsupported HPKE suite: " + "kem=0x%04x kdf=0x%04x aead=0x%04x", + tmp.kem_id, tmp.kdf_id, tmp.aead_id); + } + } + else { + VALUE str = rb_sprintf("%"PRIsVALUE",%"PRIsVALUE",%"PRIsVALUE, + kem, kdf, aead); + + if (OSSL_HPKE_str2suite(StringValueCStr(str), &tmp) != 1) + ossl_raise(eHPKEError, "unsupported HPKE suite: %"PRIsVALUE, str); + } + + suite = ALLOC(OSSL_HPKE_SUITE); + *suite = tmp; + RTYPEDDATA_DATA(self) = suite; + + /* + * A Suite is immutable: its algorithm IDs never change, and they are + * copied into the Context at construction rather than read back later. + * Freeze it so the immutability is enforced and visible to callers. + */ + return rb_obj_freeze(self); +} + +/* + * call-seq: + * kem_id -> integer + * + * Returns the IANA KEM (Key Encapsulation Mechanism) algorithm ID of the + * suite as an Integer. + */ +static VALUE +ossl_hpke_suite_kem_id(VALUE self) +{ + OSSL_HPKE_SUITE *suite; + GetHpkeSuite(self, suite); + return INT2NUM(suite->kem_id); +} + +/* + * call-seq: + * kdf_id -> integer + * + * Returns the IANA KDF (Key Derivation Function) algorithm ID of the suite + * as an Integer. + */ +static VALUE +ossl_hpke_suite_kdf_id(VALUE self) +{ + OSSL_HPKE_SUITE *suite; + GetHpkeSuite(self, suite); + return INT2NUM(suite->kdf_id); +} + +/* + * call-seq: + * aead_id -> integer + * + * Returns the IANA AEAD algorithm ID of the suite as an Integer. + */ +static VALUE +ossl_hpke_suite_aead_id(VALUE self) +{ + OSSL_HPKE_SUITE *suite; + GetHpkeSuite(self, suite); + return INT2NUM(suite->aead_id); +} + +/* private */ +static VALUE +ossl_hpke_ctx_alloc(VALUE klass) +{ + return TypedData_Wrap_Struct(klass, &ossl_hpke_ctx_type, NULL); +} + +static VALUE +ossl_hpke_suite_alloc(VALUE klass) +{ + return TypedData_Wrap_Struct(klass, &ossl_hpke_suite_type, NULL); +} + +/* HPKE module method */ +/* + * call-seq: + * keygen(suite) -> pkey + * + * Takes a OpenSSL::HPKE::Suite and returns a public-private key pair + * in the form of OpenSSL::PKey for the corresponding cipher suite. + */ +static VALUE +ossl_hpke_keygen(VALUE self, VALUE suite) +{ + EVP_PKEY *pkey; + VALUE pkey_obj; + OSSL_HPKE_SUITE *suite_st; + /* 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"); + GetHpkeSuite(suite, suite_st); + + /* set to the maximum length first; OSSL_HPKE_keygen() shrinks it down */ + publen = 133; + + if(!OSSL_HPKE_keygen(*suite_st, 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(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); + + rb_define_module_function(mHPKE, "keygen", ossl_hpke_keygen, 1); + + /* suite accessors for Suite (read from the wrapped OSSL_HPKE_SUITE) */ + rb_define_alloc_func(cSuite, ossl_hpke_suite_alloc); + rb_define_method(cSuite, "initialize", ossl_hpke_suite_initialize, 3); + rb_define_method(cSuite, "kem_id", ossl_hpke_suite_kem_id, 0); + rb_define_method(cSuite, "kdf_id", ossl_hpke_suite_kdf_id, 0); + rb_define_method(cSuite, "aead_id", ossl_hpke_suite_aead_id, 0); + + rb_define_method(cSenderContext, "initialize", ossl_hpke_ctx_new_sender, 1); + 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, 1); + 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(void) +{ +} + +#endif diff --git a/ext/openssl/ossl_hpke.h b/ext/openssl/ossl_hpke.h new file mode 100644 index 000000000..590f70072 --- /dev/null +++ b/ext/openssl/ossl_hpke.h @@ -0,0 +1,10 @@ +/* + * Ruby/OpenSSL Project + * Copyright (C) 2026 Ruby/OpenSSL Project Authors + */ +#if !defined(OSSL_HPKE_RB_H) +#define OSSL_HPKE_RB_H + +void Init_ossl_hpke(void); + +#endif diff --git a/test/openssl/test_hpke.rb b/test/openssl/test_hpke.rb new file mode 100644 index 000000000..6ac104397 --- /dev/null +++ b/test/openssl/test_hpke.rb @@ -0,0 +1,223 @@ +# 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 openssl?(3, 2, 0) + 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_with_integer_ids + # IANA IDs as carried on the wire (e.g. by ECH): X25519 / HKDF-SHA256 / + # AES-128-GCM. All three args must be Integers to take this path. + suite = OpenSSL::HPKE::Suite.new(0x0020, 0x0001, 0x0001) + assert_equal(0x0020, suite.kem_id) + assert_equal(0x0001, suite.kdf_id) + assert_equal(0x0001, suite.aead_id) + end + + def test_suite_is_frozen_after_initialization + assert_predicate(x25519_suite, :frozen?) + assert_raise(FrozenError) do + x25519_suite.instance_variable_set(:@foo, 1) + end + end + + def test_suite_new_with_integer_ids_validates_suite + # Well-formed uint16 IDs that are not a supported HPKE algorithm. + assert_raise(OpenSSL::HPKE::HPKEError) do + OpenSSL::HPKE::Suite.new(0xBEEF, 0x0001, 0x0001) + end + end + + def test_suite_new_with_integer_ids_out_of_range + assert_raise(OpenSSL::HPKE::HPKEError) do + OpenSSL::HPKE::Suite.new(0x10000, 0x0001, 0x0001) + end + 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(suite) + assert_raise(OpenSSL::HPKE::HPKEError) do + sender.send(:initialize, suite) + end + + receiver = OpenSSL::HPKE::Context::Receiver.new(suite) + assert_raise(OpenSSL::HPKE::HPKEError) do + receiver.send(:initialize, suite) + end + end + + def test_string_arguments_are_required + suite = x25519_suite + pkey = OpenSSL::HPKE.keygen(suite) + sender = OpenSSL::HPKE::Context::Sender.new(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(suite) + enc = sender.encap(public_key_bytes(pkey), info) + receiver = OpenSSL::HPKE::Context::Receiver.new(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(suite) + enc = sender.encap(public_key_bytes(pkey), info) + ct = sender.seal(aad, message) + + receiver = OpenSSL::HPKE::Context::Receiver.new(suite) + assert_equal(true, receiver.decap(enc, pkey, info)) + assert_equal(message, receiver.open(aad, ct)) + end +end + +end