diff --git a/kong/tls/plugins/certificate.lua b/kong/tls/plugins/certificate.lua new file mode 100644 index 000000000000..f53d7d967882 --- /dev/null +++ b/kong/tls/plugins/certificate.lua @@ -0,0 +1,99 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +--- Copyright 2019 Kong Inc. +local ngx_ssl = require "ngx.ssl" +local sni_filter = require("kong.tls.plugins.sni_filter") +local pl_stringx = require "pl.stringx" +local server_name = ngx_ssl.server_name +local PREFIX_SNIS_PSEUDO_INDEX = sni_filter.PREFIX_SNIS_PSEUDO_INDEX +local POSTFIX_SNIS_PSEUDO_INDEX = sni_filter.POSTFIX_SNIS_PSEUDO_INDEX +local startswith = pl_stringx.startswith +local endswith = pl_stringx.endswith + +local _M = {} + +local kong = kong +local EMPTY_T = {} + + +local function match_sni(snis, server_name) + local matched_sni + if server_name then + -- search plain snis + if snis[server_name] then + kong.log.debug("matched the plain sni ", server_name) + return snis[server_name] + end + + -- TODO: use radix tree to accelerate the search once we have an available implementation + -- search snis with the leftmost wildcard + for sni, sni_t in pairs(snis[POSTFIX_SNIS_PSEUDO_INDEX] or EMPTY_T) do + if endswith(server_name, sni_t.value) then + kong.log.debug(server_name, " matched the sni with the leftmost wildcard ", sni) + return sni_t + end + end + + -- search snis with the rightmost wildcard + for sni, sni_t in pairs(snis[PREFIX_SNIS_PSEUDO_INDEX] or EMPTY_T) do + if startswith(server_name, sni_t.value) then + kong.log.debug(server_name, " matched the sni with the rightmost wildcard ", sni) + return sni_t + end + end + end + + if server_name then + kong.log.debug("client sent an unknown sni ", server_name) + + else + kong.log.debug("client didn't send an sni") + end + + if snis["*"] then + kong.log.debug("mTLS is enabled globally") + return snis["*"] + end +end + +function _M.execute(snis_set) + + local server_name = server_name() + + local sni_mapping = match_sni(snis_set, server_name) + + if sni_mapping then + -- TODO: improve detection of ennoblement once we have DAO functions + -- to filter plugin configurations based on plugin name + + kong.log.debug("enabled, will request certificate from client") + + local chain + -- send CA DN list + if sni_mapping.ca_cert_chain then + kong.log.debug("set client ca certificate chain") + chain = sni_mapping.ca_cert_chain.ctx + end + + local res, err = kong.client.tls.request_client_certificate(chain) + if not res then + kong.log.err("unable to request client to present its certificate: ", + err) + end + + -- disable session resumption to prevent inability to access client + -- certificate in later phases + res, err = kong.client.tls.disable_session_reuse() + if not res then + kong.log.err("unable to disable session reuse for client certificate: ", + err) + end + end +end + +return _M diff --git a/kong/tls/plugins/sni_filter.lua b/kong/tls/plugins/sni_filter.lua new file mode 100644 index 000000000000..0203770b57c9 --- /dev/null +++ b/kong/tls/plugins/sni_filter.lua @@ -0,0 +1,283 @@ +-- This software is copyright Kong Inc. and its licensors. +-- Use of the software is subject to the agreement between your organization +-- and Kong Inc. If there is no such agreement, use is governed by and +-- subject to the terms of the Kong Master Software License Agreement found +-- at https://konghq.com/enterprisesoftwarelicense/. +-- [ END OF LICENSE 0867164ffc95e54f04670b5169c09574bdbd9bba ] + +local constants = require "kong.constants" +local openssl_x509 = require "resty.openssl.x509" +local chain_lib = require "resty.openssl.x509.chain" + +local _M = {} + +local kong = kong +local null = ngx.null +local ipairs = ipairs +local new_tab = require("table.new") + +local PREFIX_SNIS_PSEUDO_INDEX = -1 +local POSTFIX_SNIS_PSEUDO_INDEX = -2 +_M.PREFIX_SNIS_PSEUDO_INDEX = PREFIX_SNIS_PSEUDO_INDEX +_M.POSTFIX_SNIS_PSEUDO_INDEX = POSTFIX_SNIS_PSEUDO_INDEX +local TTL_FOREVER = { ttl = 0 } + +local ca_cert_cache_opts = { + l1_serializer = function(ca) + local x509, err = openssl_x509.new(ca.cert, "PEM") + if err then + return nil, err + end + + return x509 + end +} + + +-- make the table out side of function to reuse table +local key = new_tab(1, 0) + +local function load_ca(ca_id) + kong.log.debug("cache miss for CA Cert") + + key.id = ca_id + local ca, err = kong.db.ca_certificates:select(key) + if not ca then + if err then + return nil, err + end + + return nil, "CA Certificate '" .. tostring(ca_id) .. "' does not exist" + end + + return ca +end + +local function merge_ca_ids(sni, ca_ids) + sni.ca_ids = sni.ca_ids or {} + local sni_ca_ids = sni.ca_ids + + for _, ca_id in ipairs(ca_ids) do + if not sni_ca_ids[ca_id] then + sni_ca_ids[ca_id] = true + end + end +end + +local function ca_cert_cache_key(ca_id) + return "mtls:cacert:" .. ca_id +end + +local function load_routes_from_db(db, route_id, options) + kong.log.debug("cache miss for route id: " .. route_id.id) + local routes, err = db.routes:select(route_id, options) + if routes == nil then + -- the third value means "do not cache" + return nil, err, -1 + end + + return routes +end + + +local function build_snis_for_route(route, snis, send_ca_dn, ca_ids) + -- every route should have SNI or ask cert on all requests + if not route.snis or #route.snis == 0 then + snis["*"] = snis["*"] or {} + + if send_ca_dn then + merge_ca_ids(snis["*"], ca_ids) + end + + else + for _, sni in ipairs(route.snis) do + local sni_t + local idx = sni:find("*", 1, true) + + if idx == 1 then + -- store snis with the leftmost wildcard in a subtable + snis[POSTFIX_SNIS_PSEUDO_INDEX] = snis[POSTFIX_SNIS_PSEUDO_INDEX] or {} + local postfix_snis = snis[POSTFIX_SNIS_PSEUDO_INDEX] + postfix_snis[sni] = postfix_snis[sni] or { value = sni:sub(2) } + sni_t = postfix_snis[sni] + kong.log.debug("add a postfix sni ", sni) + + elseif idx == #sni then + -- store snis with the rightmost wildcard in a subtable + snis[PREFIX_SNIS_PSEUDO_INDEX] = snis[PREFIX_SNIS_PSEUDO_INDEX] or {} + local prefix_snis = snis[PREFIX_SNIS_PSEUDO_INDEX] + prefix_snis[sni] = prefix_snis[sni] or { value = sni:sub(1, -2) } + sni_t = prefix_snis[sni] + kong.log.debug("add a prefix sni ", sni) + + else + snis[sni] = snis[sni] or {} + sni_t = snis[sni] + kong.log.debug("add a plain sni ", sni) + end + + if send_ca_dn then + merge_ca_ids(sni_t, ca_ids) + end + end + end +end + + +local function get_snis_for_plugin(db, plugin, snis, options) + -- plugin applied on service + local service_pk = plugin.service + local send_ca_dn = plugin.config.send_ca_dn + local ca_ids = plugin.config.ca_certificates + + if service_pk then + for route, err in db.routes:each_for_service(service_pk, nil, options) do + if err then + return err + end + + -- XXX: strictly speaking, if a mtls plugin is also applied on the route, + -- then we should skip the plugin applied on the corresponding service, + -- as the plugin on route has a higher priority. + -- But this requires a plugin iteration on every route. + -- For performance considerations, we choose to continue. + -- Sending a few more ca dn is not a big deal, since we are already doing + -- this by merging the ca dn of mtls plugins with the same sni. + -- After all, sending some extra ca dn is better than sending nothing. + build_snis_for_route(route, snis, send_ca_dn, ca_ids) + end + + return + end + + -- plugin applied on route + local route_pk = plugin.route + if route_pk then + -- since routes entity is workspaceable, workspace id + -- needs to be passed when computing cache key + local cache_key = db.routes:cache_key(route_pk.id, nil, nil, nil, nil, plugin.ws_id) + local cache_obj = kong[constants.ENTITY_CACHE_STORE.routes] + local route, err = cache_obj:get(cache_key, TTL_FOREVER, + load_routes_from_db, db, + route_pk, options) + + if err then + return err + end + + build_snis_for_route(route, snis, send_ca_dn, ca_ids) + + return + end + + -- plugin applied on global scope + snis["*"] = snis["*"] or {} + if send_ca_dn then + merge_ca_ids(snis["*"], ca_ids) + end +end + +-- build ca_cert_chain from sni_t +local function build_ca_cert_chain(sni_t) + local ca_ids = sni_t.ca_ids + + if not ca_ids then + return true + end + + local chain, err = chain_lib.new() + if err then + return nil, err + end + + for ca_id, _ in pairs(ca_ids) do + local x509, err = kong.cache:get(ca_cert_cache_key(ca_id), ca_cert_cache_opts, + load_ca, ca_id) + if err then + return nil, err + end + + local _ + _, err = chain:add(x509) + + if err then + return nil, err + end + end + + sni_t.ca_cert_chain = chain + + return true +end + + +-- build ca_cert_chain for every sni +function _M.sni_cache_l1_serializer(snis) + for k, v in pairs(snis) do + if k == PREFIX_SNIS_PSEUDO_INDEX or + k == POSTFIX_SNIS_PSEUDO_INDEX then + for _, sni_t in pairs(v) do + local res, err = build_ca_cert_chain(sni_t) + if not res then + return nil, err + end + end + + else + local res, err = build_ca_cert_chain(v) + if not res then + return nil, err + end + end + end + + return snis +end + +local function each_enabled_plugin(entity, plugin_name) + local options = { + show_ws_id = true, + workspace = null, + search_fields = { + name = { eq = plugin_name }, + enabled = { eq = true } + } + } + + local iter = entity:each(1000, options) + local function iterator() + local element, err = iter() + if err then return nil, err end + if element == nil then return end + -- XXX + -- `search_fields` is PostgreSQL-backed instances only. + -- We also need a backstop here for Cassandra or DBless. + if element.name == plugin_name and element.enabled then return element, nil end + return iterator() + end + + return iterator +end + +function _M.build_ssl_route_filter_set(plugin_name) + kong.log.debug("building ssl route filter set for plugin name " .. plugin_name) + local db = kong.db + local snis = {} + + local options = { workspace = null } + for plugin, err in each_enabled_plugin(db.plugins, plugin_name) do + if err then + return nil, "could not load plugins: " .. err + end + + local err = get_snis_for_plugin(db, plugin, snis, options) + if err then + return nil, err + end + end + + return snis +end + + +return _M