Skip to content

fix(pkey): use EVP_PKEY_fromdata to load JWK #206

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -226,8 +226,7 @@ jobs:

- name: Get changed files
id: changed-files
run: |
echo "all_changed_files=$(git diff-tree --no-commit-id --name-only -r HEAD | tr '\n' ' ')" >> $GITHUB_OUTPUT
uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46

- name: Run Valgrind
if: contains(matrix.extras, 'valgrind')
Expand Down
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ Table of Contents
+ [pkey:set_parameters](#pkeyset_parameters)
+ [pkey:is_private](#pkeyis_private)
+ [pkey:get_key_type](#pkeyget_key_type)
+ [pkey:get_size](#pkeyget_size)
+ [pkey:get_default_digest_type](#pkeyget_default_digest_type)
+ [pkey:sign](#pkeysign)
+ [pkey:verify](#pkeyverify)
Expand Down Expand Up @@ -861,12 +862,13 @@ pkey.new(pem_or_der_text, {

When loading JWK, there are couple of caveats:
- Make sure the encoded JSON text is passed in, it must have been base64 decoded.
- Constraint `type` on JWK key is not supported, the parameters
in provided JSON will decide if a private or public key is loaded.
- When using OpenSSL 1.1.1 or lua-resty-openssl earlier than 1.6.0, constraint `type`
on JWK key is only supported on OpenSSL 3.x and lua-resty-openssl 1.6.0.
Otherwise the parameters in provided JSON will decide if a private or public key is loaded,
specifying `type` will result in an error; also public key part for `OKP` keys (the `x` parameter)
is not honored and derived from private key part (the `d` parameter) if it's specified.
- Only key type of `RSA`, `P-256`, `P-384` and `P-512` `EC`,
`Ed25519`, `X25519`, `Ed448` and `X448` `OKP` keys are supported.
- Public key part for `OKP` keys (the `x` parameter) is always not honored and derived
from private key part (the `d` parameter) if it's specified.
- Signatures and verification must use `ecdsa_use_raw` option to work with JWS standards
for EC keys. See [pkey:sign](#pkeysign) and [pkey.verify](#pkeyverify) for detail.
- When running outside of OpenResty, needs to install a JSON library (`cjson` or `dkjson`)
Expand Down Expand Up @@ -1119,19 +1121,37 @@ it's a public key.

### pkey:get_key_type

**syntax**: *obj, err = pk:get_key_type()*
**syntax**: *obj, err = pk:get_key_type(nid_only?)*

Returns a ASN1_OBJECT of key type of the private key as a table.

Starting from lua-resty-openssl 1.6.0, an optional argument `nid_only` can be set to `true`
to only return the numeric NID of the key.

```lua
local pkey, err = require("resty.openssl.pkey").new({type="X448"})

ngx.say(require("cjson").encode(pkey:get_key_type()))
-- outputs '{"ln":"X448","nid":1035,"sn":"X448","id":"1.3.101.111"}'
ngx.say(pkey:get_key_type(true))
-- outputs 1035
```

[Back to TOC](#table-of-contents)

### pkey:get_size

**syntax**: *size, err = pk:get_size()*

Returns the maximum suitable size for the output buffers for almost all
operations that can be done with pkey.

For RSA key, this is the size of the modulus.
For EC, Ed25519 and Ed448 keys, this is the size of the private key.
For DH key, this is the size of the prime modulus.

[Back to TOC](#table-of-contents)

### pkey:get_default_digest_type

**syntax**: *obj, err = pk:get_default_digest_type()*
Expand Down
163 changes: 163 additions & 0 deletions lib/resty/openssl/auxiliary/jwk.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ local bn_lib = require "resty.openssl.bn"
local digest_lib = require "resty.openssl.digest"
local encode_base64url = require "resty.openssl.auxiliary.compat".encode_base64url
local decode_base64url = require "resty.openssl.auxiliary.compat".decode_base64url
local param_lib = require "resty.openssl.param"
local json = require "resty.openssl.auxiliary.compat".json
local format_error = require "resty.openssl.err".format_error

local OPENSSL_3X = require("resty.openssl.version").OPENSSL_3X

local _M = {}

Expand Down Expand Up @@ -266,4 +270,163 @@ function _M.dump_jwk(pkey, is_priv)
return json.encode(jwk)
end


-- 3.x load_jwk

local settable_schema = {}

local function get_settable_schema(type, selection, properties)
-- the pctx can't be reused after EVP_PKEY_fromdata_settable, so we create a new here
local pctx = C.EVP_PKEY_CTX_new_from_name(nil, type, properties)
if pctx == nil then
return nil, "EVP_PKEY_CTX_new_from_name() failed"
end
ffi.gc(pctx, C.EVP_PKEY_CTX_free)

if C.EVP_PKEY_fromdata_init(pctx) ~= 1 then
return nil, "EVP_PKEY_fromdata_init() failed"
end

if settable_schema[type] then
return settable_schema[type]
end

local settable = C.EVP_PKEY_fromdata_settable(pctx, selection)
if settable == nil then
return nil, "EVP_PKEY_fromdata_settable() failed"
end

local schema = {}
param_lib.parse_params_schema(settable, schema, nil)

settable_schema[type] = schema
return schema
end

local ossl_params_jwk_mapping = {
RSA = {
n = "n",
e = "e",
d = "d",
p = "rsa-factor1",
q = "rsa-factor2",
dp = "rsa-exponent1",
dq = "rsa-exponent2",
qi = "rsa-coefficient1",
},
EC = {
x = "x",
y = "y",
d = "priv",
crv = "group",
},
OKP = {
x = "pub",
d = "priv",
},
}

local jwk_params_required_mapping = {
RSA = {
n = true,
e = true,
},
EC = {
crv = true,
x = true,
y = true,
},
OKP = {
-- crv = true, handled earlier
x = true,
},
}

function _M.load_jwk_ex(txt, ptyp, properties)
local tbl, err = json.decode(txt)
if err then
return nil, "jwk:load_jwk: error decoding JSON from JWK: " .. err
elseif type(tbl) ~= "table" then
return nil, "jwk:load_jwk: except input to be decoded as a table, got " .. type(tbl)
elseif not tbl["kty"] then
return nil, "jwk:load_jwk: missing \"kty\" parameter from JWK"
end

local kty = tbl["kty"]
tbl["kty"] = nil
local selection = ptyp == "pu" and evp_macro.EVP_PKEY_PUBLIC_KEY or evp_macro.EVP_PKEY_KEYPAIR

local pkey_name = kty
if kty == "OKP" then
pkey_name = tbl["crv"]
if not pkey_name then
return nil, "jwk:load_jwk: missing \"crv\" parameter from OKP JWK"
end
tbl["crv"] = nil
end

local ctx = ffi.new("EVP_PKEY*[1]")
local pctx = C.EVP_PKEY_CTX_new_from_name(nil, pkey_name, nil)
if pctx == nil then
return nil, "jwk:load_jwk: EVP_PKEY_CTX_new_from_name() failed"
end
ffi.gc(pctx, C.EVP_PKEY_CTX_free)

if C.EVP_PKEY_fromdata_init(pctx) ~= 1 then
return nil, "jwk:load_jwk: EVP_PKEY_fromdata_init() failed"
end

local schema, err = get_settable_schema(pkey_name, selection, properties)
if not schema then
return nil, "jwk:load_jwk: failed to get key schema for " .. pkey_name .. " key: " .. err
end

local mapping = ossl_params_jwk_mapping[kty]
if not mapping then
return nil, "jwk:load_jwk: not yet supported jwk type \"" .. (tbl["kty"] or "nil") .. "\""
end
local required = jwk_params_required_mapping[kty]

local params_t = {}

for kfrom, kto in pairs(mapping) do
local v = tbl[kfrom]
if type(v) == "string" and (selection == evp_macro.EVP_PKEY_KEYPAIR or required[kfrom]) then
if kfrom ~= "crv" then
v = decode_base64url(v)
if not v then
return nil, "jwk:load_jwk: cannot decode parameter \"" .. kfrom .. "\" from base64 " .. tbl[kfrom]
end
v = v:reverse() -- endian switch
end

params_t[kto] = v
elseif required[kfrom] then
return nil, "jwk:load_jwk: missing required parameter \"" .. kfrom .. "\""
end
end

if kty == "EC" then
if params_t["x"] and params_t["y"] then
params_t["pub"] = "\x04" .. params_t["x"]:reverse() .. params_t["y"]:reverse()
params_t["x"], params_t["y"] = nil, nil
end
end

local params, err = param_lib.construct(params_t, nil, schema)
if params == nil then
return nil, "jwk:load_jwk: failed to construct parameters for " .. kty .. " key: " .. err
end

if C.EVP_PKEY_fromdata(pctx, ctx, selection, params) ~= 1 then
return nil, format_error("jwk:load_jwk: EVP_PKEY_fromdata()")
end

return ctx[0]
end

if OPENSSL_3X then
_M.load_jwk = _M.load_jwk_ex
end

return _M
15 changes: 15 additions & 0 deletions lib/resty/openssl/include/evp.lua
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,25 @@ local _M = {
EVP_PKEY_CTRL_SCRYPT_MAXMEM_BYTES = EVP_PKEY_ALG_CTRL + 13,
}

local OSSL_KEYMGMT_SELECT_PRIVATE_KEY = 0x01
local OSSL_KEYMGMT_SELECT_PUBLIC_KEY = 0x02
local OSSL_KEYMGMT_SELECT_DOMAIN_PARAMETERS = 0x04
local OSSL_KEYMGMT_SELECT_OTHER_PARAMETERS = 0x80
local OSSL_KEYMGMT_SELECT_ALL_PARAMETERS = OSSL_KEYMGMT_SELECT_DOMAIN_PARAMETERS +
OSSL_KEYMGMT_SELECT_OTHER_PARAMETERS
-- local OSSL_KEYMGMT_SELECT_KEYPAIR = OSSL_KEYMGMT_SELECT_PRIVATE_KEY +
-- OSSL_KEYMGMT_SELECT_PUBLIC_KEY
-- local OSSL_KEYMGMT_SELECT_ALL = OSSL_KEYMGMT_SELECT_KEYPAIR +
-- OSSL_KEYMGMT_SELECT_ALL_PARAMETERS

if not OPENSSL_3X then
_M.EVP_PKEY_OP_CRYPT = _M.EVP_PKEY_OP_ENCRYPT + _M.EVP_PKEY_OP_DECRYPT
_M.EVP_PKEY_OP_SIG = _M.EVP_PKEY_OP_SIGN + _M.EVP_PKEY_OP_VERIFY + _M.EVP_PKEY_OP_VERIFYRECOVER +
_M.EVP_PKEY_OP_SIGNCTX + _M.EVP_PKEY_OP_VERIFYCTX
else
_M.EVP_PKEY_KEY_PARAMETERS = OSSL_KEYMGMT_SELECT_ALL_PARAMETERS
_M.EVP_PKEY_PUBLIC_KEY = _M.EVP_PKEY_KEY_PARAMETERS + OSSL_KEYMGMT_SELECT_PUBLIC_KEY
_M.EVP_PKEY_KEYPAIR = _M.EVP_PKEY_PUBLIC_KEY + OSSL_KEYMGMT_SELECT_PRIVATE_KEY
end

-- clean up error occurs during OBJ_txt2*
Expand Down
8 changes: 8 additions & 0 deletions lib/resty/openssl/include/evp/pkey.lua
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ if OPENSSL_3X then
int EVP_PKEY_set_params(EVP_PKEY *pkey, OSSL_PARAM params[]);
int EVP_PKEY_get_params(EVP_PKEY *ctx, OSSL_PARAM params[]);
const OSSL_PARAM *EVP_PKEY_gettable_params(EVP_PKEY *ctx);

EVP_PKEY_CTX *EVP_PKEY_CTX_new_from_name(OSSL_LIB_CTX *libctx,
const char *name,
const char *propquery);
int EVP_PKEY_fromdata_init(EVP_PKEY_CTX *ctx);
int EVP_PKEY_fromdata(EVP_PKEY_CTX *ctx, EVP_PKEY **ppkey, int selection,
OSSL_PARAM params[]);
const OSSL_PARAM *EVP_PKEY_fromdata_settable(EVP_PKEY_CTX *ctx, int selection);
]]

_M.EVP_PKEY_CTX_set_ec_paramgen_curve_nid = function(pctx, nid)
Expand Down
32 changes: 22 additions & 10 deletions lib/resty/openssl/param.lua
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,14 @@ local function construct(buf_t, length, types_map, types_size)
buf = ffi_new("char[?]", size)
end
else
local numeric = type(value) == "number"
if (numeric and typ >= OSSL_PARAM_UTF8_STRING) or
(not numeric and typ <= OSSL_PARAM_UNSIGNED_INTEGER) then
local string_input = type(value) == "string"
-- binary input is also accepted as string
local integer_input = type(value) == "number" or string_input
if (not string_input and typ >= OSSL_PARAM_UTF8_STRING) or
(not integer_input and typ <= OSSL_PARAM_UNSIGNED_INTEGER) then
local alter_typ = types_map[alter_type_key] and types_map[alter_type_key][key]
if alter_typ and ((numeric and alter_typ <= OSSL_PARAM_UNSIGNED_INTEGER) or
(not numeric and alter_typ >= OSSL_PARAM_UTF8_STRING)) then
if alter_typ and ((not string_input and alter_typ <= OSSL_PARAM_UNSIGNED_INTEGER) or
(not integer_input and alter_typ >= OSSL_PARAM_UTF8_STRING)) then
typ = alter_typ
else
return nil, "param:construct: key \"" .. key .. "\" can't be a " .. type(value)
Expand All @@ -68,12 +70,22 @@ local function construct(buf_t, length, types_map, types_size)
buf_param = buf_param or {}
buf_param[key] = param
elseif typ == OSSL_PARAM_INTEGER then
buf = value and ffi_new("int[1]", value) or ffi_new("int[1]")
param = C.OSSL_PARAM_construct_int(key, buf)
if value and type(value) == "string" then -- in only
local bin = ffi_cast("char *", value)
param = C.OSSL_PARAM_construct_BN(key, bin, #value)
else
buf = value and ffi_new("int[1]", value) or ffi_new("int[1]")
param = C.OSSL_PARAM_construct_int(key, buf)
end
elseif typ == OSSL_PARAM_UNSIGNED_INTEGER then
buf = value and ffi_new("unsigned int[1]", value) or
ffi_new("unsigned int[1]")
param = C.OSSL_PARAM_construct_uint(key, buf)
if value and type(value) == "string" then -- in only
local bin = ffi_cast("char *", value)
param = C.OSSL_PARAM_construct_BN(key, bin, #value)
else
buf = value and ffi_new("unsigned int[1]", value) or
ffi_new("unsigned int[1]")
param = C.OSSL_PARAM_construct_uint(key, buf)
end
elseif typ == OSSL_PARAM_UTF8_STRING then
buf = value ~= nil and ffi_cast("char *", value) or buf
param = C.OSSL_PARAM_construct_utf8_string(key, buf, value and #value or size)
Expand Down
15 changes: 10 additions & 5 deletions lib/resty/openssl/pkey.lua
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ local function load_pem_der(txt, opts, funcs)
return nil, "expecting 'pr', 'pu' or '*' as \"type\""
end

if fmt == "JWK" and (typ == "pu" or type == "pr") then
return nil, "explictly load private or public key from JWK format is not supported"
if fmt == "JWK" and (typ == "pu" or type == "pr") and not OPENSSL_3X then
return nil, "explictly load private or public key from JWK format is not supported on OpenSSL 1.1.1"
end

log_debug("load key using fmt: ", fmt, ", type: ", typ)
Expand All @@ -85,7 +85,7 @@ local function load_pem_der(txt, opts, funcs)
-- don't need BIO when loading JWK key: we parse it in Lua land
if f == "load_jwk" then
local err
ctx, err = jwk_lib[f](txt)
ctx, err = jwk_lib[f](txt, typ, opts and opts.properties)
if ctx == nil then
-- if fmt is explictly set to JWK, we should return an error now
if fmt == "JWK" then
Expand Down Expand Up @@ -484,6 +484,7 @@ local load_key_try_funcs = {} do
},
JWK = {
pr = { ['load_jwk'] = {}, },
pu = { ['load_jwk'] = {}, },
}
}
-- populate * funcs
Expand Down Expand Up @@ -624,8 +625,12 @@ function _M.istype(l)
return l and l.ctx and ffi.istype(evp_pkey_ptr_ct, l.ctx)
end

function _M:get_key_type()
return objects_lib.nid2table(self.key_type)
function _M:get_key_type(nid_only)
return nid_only and self.key_type or objects_lib.nid2table(self.key_type)
end

function _M:get_size()
return OPENSSL_3X and C.EVP_PKEY_get_size(self.ctx) or C.EVP_PKEY_size(self.ctx)
end

function _M:get_default_digest_type()
Expand Down
1 change: 1 addition & 0 deletions lib/resty/openssl/version.lua
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ return setmetatable({
return ffi_str(info_func(t))
end,
-- the following has implict upper bound of 4.x
OPENSSL_30 = version_num >= 0x30000000 and version_num < 0x30100000,
OPENSSL_3X = version_num >= 0x30000000 and version_num < 0x40000000,
OPENSSL_111 = version_num >= 0x10101000 and version_num < 0x10200000,
}, {
Expand Down
Loading