From: Vladimír Čunát Date: Tue, 21 May 2019 13:38:14 +0000 (+0200) Subject: modules/http: also send intermediate TLS certificate X-Git-Tag: v4.1.0~20^2~4 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=b7e5960bb520ec85b8d78ad4bb6859997744a79b;p=thirdparty%2Fknot-resolver.git modules/http: also send intermediate TLS certificate - separate certificate handling into a new file (+ rename the functions) - handle a list of certs instead of a single one - minor nitpicks --- diff --git a/NEWS b/NEWS index 13856812c..ded82c4f1 100644 --- 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 -------- diff --git a/modules/http/README.rst b/modules/http/README.rst index 1b5a40f4f..8644a0794 100644 --- a/modules/http/README.rst +++ b/modules/http/README.rst @@ -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 `_? +.. 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: diff --git a/modules/http/http.lua.in b/modules/http/http.lua.in index 6815d74b7..86db45d53 100644 --- a/modules/http/http.lua.in +++ b/modules/http/http.lua.in @@ -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 index 000000000..7c2291958 --- /dev/null +++ b/modules/http/http_tls_cert.lua @@ -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 + diff --git a/modules/http/meson.build b/modules/http/meson.build index 518f96f30..7a6ab8a7b 100644 --- a/modules/http/meson.build +++ b/modules/http/meson.build @@ -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'), ]