]> git.ipfire.org Git - thirdparty/knot-resolver.git/commitdiff
modules/http: also send intermediate TLS certificate
authorVladimír Čunát <vladimir.cunat@nic.cz>
Tue, 21 May 2019 13:38:14 +0000 (15:38 +0200)
committerTomas Krizek <tomas.krizek@nic.cz>
Mon, 17 Jun 2019 13:49:53 +0000 (15:49 +0200)
- separate certificate handling into a new file (+ rename the functions)
- handle a list of certs instead of a single one
- minor nitpicks

NEWS
modules/http/README.rst
modules/http/http.lua.in
modules/http/http_tls_cert.lua [new file with mode: 0644]
modules/http/meson.build

diff --git a/NEWS b/NEWS
index 13856812cf2219bcc88a764701900b0533db22aa..ded82c4f18104f80c450fd4bd6275c2a559794a0 100644 (file)
--- a/NEWS
+++ b/NEWS
@@ -10,6 +10,8 @@ Improvements
 - lua modules may omit casting parameters of layer functions (!797)
 - lua tables for C modules are more strict by default, e.g. `nsid.foo`
   will throw an error instead of returning `nil` (!797)
+- http module: also send intermediate TLS certificate to clients,
+  if available and luaossl >= 20181207 (!819)
 
 Bugfixes
 --------
index 1b5a40f4fdf839a6963f5db4bd78f95bdf6dbb6b..8644a0794f381a044de8457da4eed12e88ead1a0 100644 (file)
@@ -71,6 +71,10 @@ TLS certificate that is valid for 90 days and is automatically renewed. It is of
 course self-signed. Why not use something like
 `Let's Encrypt <https://letsencrypt.org>`_?
 
+.. warning::
+
+   If you use package ``luaossl < 20181207``, intermediate certificate is not sent to clients,
+   which may cause problems with validating the connection in some cases.
 
 You can disable unecrypted HTTP and enforce HTTPS by passing
 ``tls = true`` option for all HTTP endpoints:
index 6815d74b7b493bac7516761f851e9821dc2db9cb..86db45d5312edb6b3ab6dfa8236408b3ffcfeb81 100644 (file)
@@ -16,9 +16,11 @@ local http_server = require('http.server')
 local http_headers = require('http.headers')
 local http_websocket = require('http.websocket')
 local http_util = require "http.util"
-local x509, pkey = require('openssl.x509'), require('openssl.pkey')
 local has_mmdb, mmdb  = pcall(require, 'mmdb')
 
+-- A sub-module for certificate management.
+local tls_cert = require('kres_modules.http_tls_cert')
+
 -- Module declaration
 local M = {
        servers = {},
@@ -252,76 +254,6 @@ local function route(endpoints)
        end
 end
 
--- @function Create self-signed certificate
-local function ephemeralcert(host)
-       -- Import luaossl directly
-       local name = require('openssl.x509.name')
-       local altname = require('openssl.x509.altname')
-       local openssl_bignum = require('openssl.bignum')
-       local openssl_rand = require('openssl.rand')
-       -- Create self-signed certificate
-       host = host or hostname()
-       local crt = x509.new()
-       local now = os.time()
-       crt:setVersion(3)
-       -- serial needs to be unique or browsers will show uninformative error messages
-       crt:setSerial(openssl_bignum.fromBinary(openssl_rand.bytes(16)))
-       -- use the host we're listening on as canonical name
-       local dn = name.new()
-       dn:add("CN", host)
-       crt:setSubject(dn)
-       crt:setIssuer(dn) -- should match subject for a self-signed
-       local alt = altname.new()
-       alt:add("DNS", host)
-       crt:setSubjectAlt(alt)
-       -- Valid for 90 days
-       crt:setLifetime(now, now + 90*60*60*24)
-       -- Can't be used as a CA
-       crt:setBasicConstraints{CA=false}
-       crt:setBasicConstraintsCritical(true)
-       -- Create and set key (default: EC/P-256 as a most "interoperable")
-       local key = pkey.new {type = 'EC', curve = 'prime256v1'}
-       crt:setPublicKey(key)
-       crt:sign(key)
-       return crt, key
-end
-
--- @function Prefer HTTP/2 or HTTP/1.1
-local function alpnselect(_, protos)
-       for _, proto in ipairs(protos) do
-               if proto == 'h2' or proto == 'http/1.1' then
-                       return proto
-               end
-       end
-       return nil
-end
-
--- @function Create TLS context
-local function tlscontext(crt, key)
-       local http_tls = require('http.tls')
-       local ctx = http_tls.new_server_context()
-       if ctx.setAlpnSelect then
-               ctx:setAlpnSelect(alpnselect)
-       end
-       assert(ctx:setPrivateKey(key))
-       assert(ctx:setCertificate(crt))
-       return ctx
-end
-
--- @function Refresh self-signed certificates
-local function updatecert(crtfile, keyfile)
-       local f = assert(io.open(crtfile, 'w'), string.format('cannot open "%s" for writing', crtfile))
-       local crt, key = ephemeralcert()
-       -- Write back to file
-       f:write(tostring(crt))
-       f:close()
-       f = assert(io.open(keyfile, 'w'), string.format('cannot open "%s" for writing', keyfile))
-       local pub, priv = key:toPEM('public', 'private')
-       assert(f:write(pub..priv))
-       f:close()
-       return crt, key
-end
-
 -- @function Merge dictionaries, nil is like empty dict.
 -- Values from right-hand side dictionaries take precedence.
 local function mergeconf(...)
@@ -340,52 +272,29 @@ local function mergeconf(...)
        return merged
 end
 
-local function load_cert(certname, keyname)
-       local f, err = io.open(certname, 'r')
-       if not f then
-               panic('[http] unable to read TLS certificate file: %s', err)
-       end
-       local crt = x509.new(f:read('*all'))
-       f:close()
-       if not crt then
-               panic('[http] unable to parse TLS certificate file %s', certname)
-       end
-
-       f, err = io.open(keyname, 'r')
-       if not f then
-               panic('[http] unable to open TLS key file: %s', err)
-       end
-       local key = pkey.new(f:read('*all'))
-       f:close()
-       if not key then
-               panic('[http] unable to parse TLS key file %s', keyname)
-       end
-       return crt, key
-end
-
 -- @function Listen on given socket
 -- using configuration for specific "kind" of HTTP server
 local function add_socket(fd, kind, addr_str)
        assert(M.servers[fd] == nil, 'socket is already served by an HTTP instance')
-       local conf, crt, key
+       local conf, certs, key
        conf = mergeconf(M.configs._builtin._all, M.configs._builtin[kind], M.configs._all, M.configs[kind])
        conf.socket = cqueues.socket.fdopen(fd)
        if conf.tls ~= false then
                -- Check if a cert file was specified
                -- Read or create self-signed x509 certificate
                if conf.ephemeral then
-                       crt, key = updatecert(conf.cert, conf.key)
+                       certs, key = tls_cert.new_ephemeral_files(conf.cert, conf.key)
                else
-                       crt, key = load_cert(conf.cert, conf.key)
+                       certs, key = tls_cert.load(conf.cert, conf.key)
                end
                -- Check loaded certificate
-               if not crt or not key then
+               if not certs or not key then
                        panic('failed to load certificate "%s"', conf.cert)
                end
        end
        -- Compose server handler
        local routes = route(conf.endpoints)
-       conf.ctx = crt and tlscontext(crt, key)
+       conf.ctx = certs and tls_cert.new_tls_context(certs, key)
        conf.onstream = routes
        -- Create TLS context and start listening
        local s, err = http_server.new(conf)
@@ -397,15 +306,15 @@ local function add_socket(fd, kind, addr_str)
                panic('failed to listen on %s: %s', addr_str, err)
        end
        M.servers[fd] = {kind = kind, server = s, config = conf}
-       -- Create certificate renewal timer if ephemeral
-       if crt and conf.ephemeral then
-               local _, expiry = crt:getLifetime()
+       -- Create certificate renewal timer if ephemeral; FIXME: renew more than once, not per-socket? etc.
+       if certs and conf.ephemeral then
+               local _, expiry = certs[1]:getLifetime()
                expiry = 1000 * math.max(0, expiry - (os.time() - 3 * 24 * 3600))
                event.after(expiry, function ()
                        log('[http] refreshed ephemeral certificate')
-                       crt, key = updatecert(conf.cert, conf.key)
+                       certs, key = tls_cert.new_ephemeral_files(conf.cert, conf.key)
                        -- TODO servers sharing cert?!
-                       s.ctx = tlscontext(crt, key)
+                       s.ctx = tls_cert.new_tls_context(certs, key)
                end)
        end
 end
@@ -487,7 +396,7 @@ function M.config(conf, kind)
                                panic('[http] certificate provided, but missing key')
                        end
                        -- test if it can be loaded or not
-                       load_cert(conf.cert, conf.key)
+                       tls_cert.load(conf.cert, conf.key)
                end
                if conf.geoip then
                        if has_mmdb then
diff --git a/modules/http/http_tls_cert.lua b/modules/http/http_tls_cert.lua
new file mode 100644 (file)
index 0000000..7c22919
--- /dev/null
@@ -0,0 +1,157 @@
+--[[
+       Conventions:
+               - key = private+public key-pair in openssl.pkey format
+               - certs = lua list of certificates (at least one), each in openssl.x509 format,
+                       ordered from leaf to almost-root
+               - panic('...') is used on bad problems instead of returning nils or such
+--]]
+local tls_cert = {}
+
+local x509, pkey = require('openssl.x509'), require('openssl.pkey')
+
+-- @function Create self-signed certificate; return certs, key
+local function new_ephemeral(host)
+       -- Import luaossl directly
+       local name = require('openssl.x509.name')
+       local altname = require('openssl.x509.altname')
+       local openssl_bignum = require('openssl.bignum')
+       local openssl_rand = require('openssl.rand')
+       -- Create self-signed certificate
+       host = host or hostname()
+       local crt = x509.new()
+       local now = os.time()
+       crt:setVersion(3)
+       -- serial needs to be unique or browsers will show uninformative error messages
+       crt:setSerial(openssl_bignum.fromBinary(openssl_rand.bytes(16)))
+       -- use the host we're listening on as canonical name
+       local dn = name.new()
+       dn:add("CN", host)
+       crt:setSubject(dn)
+       crt:setIssuer(dn) -- should match subject for a self-signed
+       local alt = altname.new()
+       alt:add("DNS", host)
+       crt:setSubjectAlt(alt)
+       -- Valid for 90 days
+       crt:setLifetime(now, now + 90*60*60*24)
+       -- Can't be used as a CA
+       crt:setBasicConstraints{CA=false}
+       crt:setBasicConstraintsCritical(true)
+       -- Create and set key (default: EC/P-256 as a most "interoperable")
+       local key = pkey.new {type = 'EC', curve = 'prime256v1'}
+       crt:setPublicKey(key)
+       crt:sign(key)
+       return { crt }, key
+end
+
+-- @function Create new self-signed certificate, write to files; return certs, key
+-- Well, we actually never read from these files anyway (in case of ephemeral).
+function tls_cert.new_ephemeral_files(certfile, keyfile)
+       local certs, key = new_ephemeral()
+       -- Write certs
+       local f = assert(io.open(certfile, 'w'), string.format('cannot open "%s" for writing', certfile))
+       for _, cert in ipairs(certs) do
+               f:write(tostring(cert))
+       end
+       f:close()
+       -- Write key as a pair
+       f = assert(io.open(keyfile, 'w'), string.format('cannot open "%s" for writing', keyfile))
+       local pub, priv = key:toPEM('public', 'private')
+       assert(f:write(pub .. priv))
+       f:close()
+       return certs, key
+end
+
+-- @function Read a certificate chain and a key from files; return certs, key
+function tls_cert.load(certfile, keyfile)
+       -- get key
+       local f, err = io.open(keyfile, 'r')
+       if not f then
+               panic('[http] unable to open TLS key file: %s', err)
+       end
+       local key = pkey.new(f:read('*all'))
+       f:close()
+       if not key then
+               panic('[http] unable to parse TLS key file %s', keyfile)
+       end
+
+       -- get certs list
+       local certs = {}
+       local f, err = io.open(certfile, 'r')
+       if not f then
+               panic('[http] unable to read TLS certificate file: %s', err)
+       end
+       while true do
+               -- Get the next "block" = single certificate as PEM string.
+               local block = nil
+               local line
+               repeat
+                       line = f:read()
+                       if not line then break end
+                       if block then
+                               block = block .. '\n' .. line
+                       else
+                               block = line
+                       end
+                       -- separator: "posteb" in https://tools.ietf.org/html/rfc7468#section-3
+               until string.sub(line, 1, 9) == '-----END '
+               -- Empty block means clean EOF.
+               if not block then break end
+               if not line then
+                       panic('[http] unable to parse TLS certificate file %s, certificate number %d', certfile, 1 + #certs)
+               end
+
+               -- Parse the cert and append to the list.
+               local cert = x509.new(block, 'PEM')
+               if not cert then
+                       panic('[http] unable to parse TLS certificate file %s, certificate number %d', certfile, 1 + #certs)
+               end
+               table.insert(certs, cert)
+       end
+       f:close()
+
+       return certs, key
+end
+
+
+-- @function Prefer HTTP/2 or HTTP/1.1
+local function alpnselect(_, protos)
+       for _, proto in ipairs(protos) do
+               if proto == 'h2' or proto == 'http/1.1' then
+                       return proto
+               end
+       end
+       return nil
+end
+
+local warned_old_luaossl = false
+
+-- @function Return a new TLS context for a server.
+function tls_cert.new_tls_context(certs, key)
+       local ctx = require('http.tls').new_server_context()
+       if ctx.setAlpnSelect then
+               ctx:setAlpnSelect(alpnselect)
+       end
+       assert(ctx:setPrivateKey(key))
+       assert(ctx:setCertificate(certs[1]))
+
+       -- Set up certificate chain to be sent, if required and possible.
+       if #certs == 1 then return ctx end
+       if ctx.setCertificateChain then
+               local chain = require('openssl.x509.chain').new()
+               assert(chain)
+               for i = 2, #certs do
+                       chain:add(certs[i])
+                       assert(chain)
+               end
+               assert(ctx:setCertificateChain(chain))
+       elseif not warned_old_luaossl then
+               -- old luaossl version -> only final cert sent to clients
+               warn('[http] Warning: need luaossl >= 20181207 to support sending intermediary certificate to clients')
+               warned_old_luaossl = true
+       end
+       return ctx
+end
+
+
+return tls_cert
+
index 518f96f3050834eeb95324df8e49fdd3796dafa5..7a6ab8a7b204c5e73461ed9154018dba505da7a8 100644 (file)
@@ -13,6 +13,7 @@ lua_mod_src += [
   lua_http,
   files('http_doh.lua'),
   files('http_trace.lua'),
+  files('http_tls_cert.lua'),
   files('prometheus.lua'),
 ]