From 0cd875fa764dd7e40f1752c1958557e8393d0a1e Mon Sep 17 00:00:00 2001 From: Kazuki Yamaguchi Date: Sat, 24 May 2025 05:31:36 +0900 Subject: [PATCH 1/3] ossl.c: add ossl_make_params() Add a helper function to make an array of OSSL_PARAM from Enumerable, which can be passed to OpenSSL 3.0's *_set_params() functions. In the next patch, we will use this with the EVP_KDF API to implement OpenSSL::KDF.derive. --- ext/openssl/ossl.c | 106 ++++++++++++++++++++++++++++++++++++++++++ ext/openssl/ossl.h | 8 ++++ ext/openssl/ossl_bn.c | 8 ++-- ext/openssl/ossl_bn.h | 1 + 4 files changed, 119 insertions(+), 4 deletions(-) diff --git a/ext/openssl/ossl.c b/ext/openssl/ossl.c index 6438d96fd..d0dd084e9 100644 --- a/ext/openssl/ossl.c +++ b/ext/openssl/ossl.c @@ -221,6 +221,112 @@ ossl_pem_passwd_cb(char *buf, int max_len, int flag, void *pwd_) return (int)len; } +#ifdef OSSL_PARAM_INTEGER +#include + +struct make_params_args { + const OSSL_PARAM *settable; + VALUE ary; + OSSL_PARAM_BLD *bld; +}; + +static VALUE +make_params_push_i(RB_BLOCK_CALL_FUNC_ARGLIST(i, args_)) +{ + struct make_params_args *args = (struct make_params_args *)args_; + + Check_Type(i, T_ARRAY); + VALUE keyv = rb_ary_entry(i, 0), obj = rb_ary_entry(i, 1); + if (SYMBOL_P(keyv)) + keyv = rb_sym2str(keyv); + StringValueCStr(keyv); + + const OSSL_PARAM *p = + OSSL_PARAM_locate_const(args->settable, RSTRING_PTR(keyv)); + if (!p) { + VALUE keys = rb_ary_new(); + for (p = args->settable; p->key; p++) + rb_ary_push(keys, rb_str_new_cstr(p->key)); + rb_raise(rb_eArgError, "unrecognized OSSL_PARAM key %"PRIsVALUE" " \ + "(supported keys: %"PRIsVALUE")", + keyv, rb_ary_join(keys, rb_str_new_cstr(", "))); + } + + switch (p->data_type) { + case OSSL_PARAM_INTEGER: + case OSSL_PARAM_UNSIGNED_INTEGER: + obj = ossl_try_convert_to_bn(obj); + if (NIL_P(obj)) + rb_raise(rb_eArgError, "OSSL_PARAM key %s expects " \ + "integer value", p->key); + const BIGNUM *bn = GetBNPtr(obj); + if (p->data_type == OSSL_PARAM_UNSIGNED_INTEGER && BN_is_negative(bn)) + rb_raise(rb_eArgError, "OSSL_PARAM key %s expects " \ + "non-negative integer value", p->key); + if (!OSSL_PARAM_BLD_push_BN(args->bld, p->key, GetBNPtr(obj))) + ossl_raise(eOSSLError, "OSSL_PARAM_BLD_push_BN"); + break; + case OSSL_PARAM_UTF8_STRING: + obj = rb_check_string_type(obj); + if (NIL_P(obj) || memchr(RSTRING_PTR(obj), 0, RSTRING_LEN(obj))) + rb_raise(rb_eArgError, "OSSL_PARAM key %s expects " \ + "NUL-terminated string value", p->key); + if (!OSSL_PARAM_BLD_push_utf8_string(args->bld, p->key, RSTRING_PTR(obj), + RSTRING_LEN(obj))) + ossl_raise(eOSSLError, "OSSL_PARAM_BLD_push_utf8_string"); + break; + case OSSL_PARAM_OCTET_STRING: + obj = rb_check_string_type(obj); + if (NIL_P(obj)) + rb_raise(rb_eArgError, "OSSL_PARAM key %s expects string value", + p->key); + if (!OSSL_PARAM_BLD_push_octet_string(args->bld, p->key, RSTRING_PTR(obj), + RSTRING_LEN(obj))) + ossl_raise(eOSSLError, "OSSL_PARAM_BLD_push_octet_string"); + break; + default: + /* + * As of OpenSSL 4.0, the following data types are defined, but are + * not actually used by the builtin providers. So leave them + * unimplemented for now: + * - OSSL_PARAM_REAL (double) + * - OSSL_PARAM_UTF8_PTR (C string) + * - OSSL_PARAM_OCTET_PTR (unknown) + */ + rb_raise(eOSSLError, "unsupported OSSL_PARAM data type %d for key %s", + p->data_type, p->key); + } + return Qnil; +} + +static VALUE +make_params_i(VALUE args_) +{ + struct make_params_args *args = (struct make_params_args *)args_; + + args->bld = OSSL_PARAM_BLD_new(); + if (!args->bld) + ossl_raise(eOSSLError, "OSSL_PARAM_BLD_new"); + + rb_block_call(args->ary, rb_intern("each"), 0, NULL, make_params_push_i, + (VALUE)args); + + OSSL_PARAM *ret = OSSL_PARAM_BLD_to_param(args->bld); + if (!ret) + ossl_raise(eOSSLError, "OSSL_PARAM_BLD_to_param"); + return (VALUE)ret; +} + +OSSL_PARAM * +ossl_make_params(const OSSL_PARAM *settable, VALUE ary, int *state) +{ + struct make_params_args args = { settable, ary, NULL }; + VALUE params = rb_protect(make_params_i, (VALUE)&args, state); + OSSL_PARAM_BLD_free(args.bld); + return (OSSL_PARAM *)params; +} +#endif + /* * main module */ diff --git a/ext/openssl/ossl.h b/ext/openssl/ossl.h index 133cc0126..06f29330d 100644 --- a/ext/openssl/ossl.h +++ b/ext/openssl/ossl.h @@ -166,6 +166,14 @@ void ossl_clear_error(void); VALUE ossl_to_der(VALUE); VALUE ossl_to_der_if_possible(VALUE); +#ifdef OSSL_PARAM_INTEGER +/* + * Make an OSSL_PARAM array from Hash/Enumerable. The OSSL_PARAM array is + * allocated by OpenSSL's malloc and must be freed by OSSL_PARAM_free(). + */ +OSSL_PARAM *ossl_make_params(const OSSL_PARAM *settable, VALUE ary, int *state); +#endif + /* * Debug */ diff --git a/ext/openssl/ossl_bn.c b/ext/openssl/ossl_bn.c index 9014f2df2..c06ecd646 100644 --- a/ext/openssl/ossl_bn.c +++ b/ext/openssl/ossl_bn.c @@ -114,8 +114,8 @@ integer_to_bnptr(VALUE obj, BIGNUM *orig) return bn; } -static VALUE -try_convert_to_bn(VALUE obj) +VALUE +ossl_try_convert_to_bn(VALUE obj) { BIGNUM *bn; VALUE newobj = Qnil; @@ -137,7 +137,7 @@ ossl_bn_value_ptr(volatile VALUE *ptr) VALUE tmp; BIGNUM *bn; - tmp = try_convert_to_bn(*ptr); + tmp = ossl_try_convert_to_bn(*ptr); if (NIL_P(tmp)) ossl_raise(rb_eTypeError, "Cannot convert into OpenSSL::BN"); GetBN(tmp, bn); @@ -1047,7 +1047,7 @@ ossl_bn_eq(VALUE self, VALUE other) BIGNUM *bn1, *bn2; GetBN(self, bn1); - other = try_convert_to_bn(other); + other = ossl_try_convert_to_bn(other); if (NIL_P(other)) return Qfalse; GetBN(other, bn2); diff --git a/ext/openssl/ossl_bn.h b/ext/openssl/ossl_bn.h index 0c186bd1c..38a7e51e7 100644 --- a/ext/openssl/ossl_bn.h +++ b/ext/openssl/ossl_bn.h @@ -18,6 +18,7 @@ BN_CTX *ossl_bn_ctx_get(void); #define GetBNPtr(obj) ossl_bn_value_ptr(&(obj)) VALUE ossl_bn_new(const BIGNUM *); +VALUE ossl_try_convert_to_bn(VALUE obj); BIGNUM *ossl_bn_value_ptr(volatile VALUE *); void Init_ossl_bn(void); From d6a4e163924d469730a966045cfa3ce6d14aab7f Mon Sep 17 00:00:00 2001 From: Kazuki Yamaguchi Date: Sat, 24 May 2025 05:32:25 +0900 Subject: [PATCH 2/3] kdf: add OpenSSL::KDF.derive Expose EVP_KDF_derive() added in OpenSSL 3.0. The Argon2 password hashing algorithm added in OpenSSL 3.2 is available exclusively through this API. This is a low-level and minimum method to interact with the API. You will have to carefully read the relevant man pages to use this correctly. --- ext/openssl/extconf.rb | 1 + ext/openssl/ossl_kdf.c | 99 ++++++++++++++++++++++++++++++++++++++++ test/openssl/test_kdf.rb | 41 +++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/ext/openssl/extconf.rb b/ext/openssl/extconf.rb index dcfc7e43c..7867aff92 100644 --- a/ext/openssl/extconf.rb +++ b/ext/openssl/extconf.rb @@ -172,6 +172,7 @@ def find_openssl_library have_func("EVP_PKEY_eq(NULL, NULL)", evp_h) have_func("EVP_PKEY_dup(NULL)", evp_h) have_func("EVP_PKEY_encapsulate_init(NULL, NULL)", evp_h) +have_func("EVP_KDF_derive(NULL, NULL, 0, NULL)", "openssl/kdf.h") # added in 3.2.0 have_func("SSL_get0_group_name(NULL)", ssl_h) diff --git a/ext/openssl/ossl_kdf.c b/ext/openssl/ossl_kdf.c index 99a3589b3..5cf0bc5d0 100644 --- a/ext/openssl/ossl_kdf.c +++ b/ext/openssl/ossl_kdf.c @@ -312,6 +312,104 @@ kdf_hkdf(int argc, VALUE *argv, VALUE self) return str; } +#ifdef HAVE_EVP_KDF_DERIVE +struct kdf_derive_args { + EVP_KDF_CTX *ctx; + unsigned char *out; + size_t outlen; + OSSL_PARAM *params; +}; + +static void * +kdf_derive_nogvl(void *args_) +{ + struct kdf_derive_args *args = args_; + int ret = EVP_KDF_derive(args->ctx, args->out, args->outlen, args->params); + return (void *)(uintptr_t)ret; +} + +/* + * call-seq: + * KDF.derive(algo, length, params) -> String + * + * Derives _length_ bytes of key material from _params_ using the \KDF algorithm + * specified by the String _algo_. This is a low-level interface to provide + * access to the +EVP_KDF+ API available with \OpenSSL 3.0 or later. + * + * _params_ specifies the set of +OSSL_PARAM+ to be passed to EVP_KDF_derive(3). + * Check the relevant EVP_KDF-* man page, or the documentation for the OpenSSL + * provider in use, for the supported parameters. + * + * See the man page EVP_KDF_derive(3) for details. + * + * === Example + * # See the man page EVP_KDF-PBKDF2(7). + * # RFC 6070 PBKDF2 HMAC-SHA1 Test Vectors, 3rd example + * # https://www.rfc-editor.org/rfc/rfc6070 + * ret = OpenSSL::KDF.derive("PBKDF2", 20, [ + * ["pass", "password"], + * ["salt", "salt"], + * ["iter", 4096], + * ["digest", "SHA1"], + * ]) + * p ret.unpack1("H*") + * #=> "4b007901b765489abead49d926f721d065a429c1" + */ +static VALUE +kdf_derive(int argc, VALUE *argv, VALUE self) +{ + VALUE algo, keylen, ary, out; + EVP_KDF *kdf; + EVP_KDF_CTX *ctx; + OSSL_PARAM *params = NULL; + + rb_scan_args(argc, argv, "21", &algo, &keylen, &ary); + out = rb_str_new(NULL, NUM2LONG(keylen)); + + kdf = EVP_KDF_fetch(NULL, StringValueCStr(algo), NULL); + if (!kdf) + ossl_raise(eKDF, "EVP_KDF_fetch"); + ctx = EVP_KDF_CTX_new(kdf); + if (!ctx) { + EVP_KDF_free(kdf); + ossl_raise(eKDF, "EVP_KDF_CTX_new"); + } + if (!NIL_P(ary)) { + const OSSL_PARAM *settable = EVP_KDF_CTX_settable_params(ctx); + int state; + if (!settable) { + EVP_KDF_CTX_free(ctx); + EVP_KDF_free(kdf); + ossl_raise(eKDF, "EVP_KDF_CTX_settable_params"); + } + params = ossl_make_params(settable, ary, &state); + if (state) { + EVP_KDF_CTX_free(ctx); + EVP_KDF_free(kdf); + rb_jump_tag(state); + } + } + + struct kdf_derive_args args = { + .ctx = ctx, + .out = (unsigned char *)RSTRING_PTR(out), + .outlen = RSTRING_LEN(out), + .params = params, + }; + int ret = (int)(uintptr_t)rb_thread_call_without_gvl(kdf_derive_nogvl, + &args, NULL, NULL); + OSSL_PARAM_free(params); + EVP_KDF_CTX_free(ctx); + EVP_KDF_free(kdf); + if (ret != 1) + ossl_raise(eKDF, "EVP_KDF_derive"); + + return out; +} +#else +#define kdf_derive rb_f_notimplement +#endif + void Init_ossl_kdf(void) { @@ -373,4 +471,5 @@ Init_ossl_kdf(void) rb_define_module_function(mKDF, "scrypt", kdf_scrypt, -1); #endif rb_define_module_function(mKDF, "hkdf", kdf_hkdf, -1); + rb_define_module_function(mKDF, "derive", kdf_derive, -1); } diff --git a/test/openssl/test_kdf.rb b/test/openssl/test_kdf.rb index 708d1883a..a6826a740 100644 --- a/test/openssl/test_kdf.rb +++ b/test/openssl/test_kdf.rb @@ -151,6 +151,47 @@ def test_hkdf_rfc5869_test_case_5 assert_equal(okm, OpenSSL::KDF.hkdf(ikm, salt: salt, info: info, length: l, hash: hash)) end + def test_derive + unless openssl?(3, 0, 0) || OpenSSL::KDF.respond_to?(:derive) + omit "EVP_KDF_derive() is not supported" + end + + # https://www.rfc-editor.org/rfc/rfc6070.html#section-2 + # PBKDF2 HMAC-SHA1 Test Vectors, 5th example + params = [ + ["pass", "passwordPASSWORDpassword"], + ["salt", "saltSALTsaltSALTsaltSALTsaltSALTsalt"], + ["iter", 4096], + ["digest", "SHA1"], + ] + dk = B("3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038") + assert_equal(dk, OpenSSL::KDF.derive("PBKDF2", 25, params)) + + params_hash = params.map { |k, v| [k.to_sym, v] }.to_h + assert_equal(dk, OpenSSL::KDF.derive("PBKDF2", 25, params_hash)) + + # param key not in settable_params + assert_raise_with_message(ArgumentError, /nosucha.*iter/) { + OpenSSL::KDF.derive("PBKDF2", 20, [["nosucha", "param"]]) + } + + # "pass" for PBKDF2 is an OSSL_PARAM_OCTET_STRING + assert_raise_with_message(ArgumentError, /pass.*string value/) { + OpenSSL::KDF.derive("PBKDF2", 20, [["pass", 123]]) + } + + # "iter" for PBKDF2 is an OSSL_PARAM_UNSIGNED_INTEGER + assert_raise_with_message(ArgumentError, /iter.*non-negative/) { + OpenSSL::KDF.derive("PBKDF2", 20, [["iter", -1]]) + } + + # "digest" for PBKDF2 is an OSSL_PARAM_UTF8_STRING, which requires a + # NUL-terminated string + assert_raise_with_message(ArgumentError, /digest.*NUL-terminated/) { + OpenSSL::KDF.derive("PBKDF2", 20, [["digest", "SHA1\0"]]) + } + end + private def B(ary) From e10d9587b3f9c0ea3d71d51a3328e8ccd5ba4632 Mon Sep 17 00:00:00 2001 From: Kazuki Yamaguchi Date: Sat, 24 May 2025 05:32:40 +0900 Subject: [PATCH 3/3] kdf: add a shorthand method for Argon2id --- lib/openssl.rb | 1 + lib/openssl/kdf.rb | 40 ++++++++++++++++++++++++++++++++++++++++ test/openssl/test_kdf.rb | 17 +++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 lib/openssl/kdf.rb diff --git a/lib/openssl.rb b/lib/openssl.rb index 98fa8d39f..8ad3462c9 100644 --- a/lib/openssl.rb +++ b/lib/openssl.rb @@ -16,6 +16,7 @@ require_relative 'openssl/cipher' require_relative 'openssl/digest' require_relative 'openssl/hmac' +require_relative 'openssl/kdf' require_relative 'openssl/pkcs5' require_relative 'openssl/pkey' require_relative 'openssl/ssl' diff --git a/lib/openssl/kdf.rb b/lib/openssl/kdf.rb new file mode 100644 index 000000000..ebb599abc --- /dev/null +++ b/lib/openssl/kdf.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module OpenSSL + module KDF + # Argon2id, a variant of Argon2, is a password hashing function + # described in {RFC 9106}[https://www.rfc-editor.org/rfc/rfc9106]. + # + # This methods requires \OpenSSL 3.2 or later. + # + # === Parameters + # pass:: Passowrd to be hashed. Message string +P+ in RFC 9106. + # salt:: Salt. Nonce +S+ in RFC 9106. + # lanes:: Degree of parallelism. +p+ in RFC 9106. + # length:: Desired output length in bytes. Tag length +T+ in RFC 9106. + # memcost:: Memory size in the number of kibibytes. +m+ in RFC 9106. + # iter:: Number of passes. +t+ in RFC 9106. + # secret:: Secret value. Optional. +K+ in RFC 9106. + # ad:: Associated data. Optional. +X+ in RFC 9106. + # + # === Example + # password = "\x01" * 32 + # salt = "\x02" * 16 + # secret = "\x03" * 8 + # ad = "\x04" * 12 + # ret = OpenSSL::KDF.argon2id( + # password, salt: salt, lanes: 4, length: 32, + # memcost: 32, iter: 3, secret: secret, ad: ad, + # ) + # p ret.unpack1("H*") + # #=> "0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659" + def self.argon2id(pass, salt:, lanes:, length:, memcost:, iter:, + secret: "", ad: "") + params = { + pass: pass, salt: salt, lanes: lanes, memcost: memcost, iter: iter, + secret: secret, ad: ad, + } + derive("ARGON2ID", length, params) + end + end +end diff --git a/test/openssl/test_kdf.rb b/test/openssl/test_kdf.rb index a6826a740..fcb183711 100644 --- a/test/openssl/test_kdf.rb +++ b/test/openssl/test_kdf.rb @@ -192,6 +192,23 @@ def test_derive } end + def test_argon2id_rfc9106 + omit_on_fips + omit "Argon2id is not supported" unless openssl?(3, 2, 0) + + # https://www.rfc-editor.org/rfc/rfc9106.html#section-5.3 + # 5.3. Argon2id Test Vectors + password = B("01" * 32) + salt = B("02" * 16) + secret = B("03" * 8) + ad = B("04" * 12) + tag = B("0d640df58d78766c08c037a34a8b53c9d0" \ + "1ef0452d75b65eb52520e96b01e659") + ret = OpenSSL::KDF.argon2id(password, salt: salt, lanes: 4, length: 32, + memcost: 32, iter: 3, secret: secret, ad: ad) + assert_equal(tag, ret) + end + private def B(ary)