Skip to content
Snippets Groups Projects
trust_anchors.lua.in 11.1 KiB
Newer Older
-- Fetch over HTTPS with peert cert checked
local function https_fetch(url, ca)
	local ssl_ok, https = pcall(require, 'ssl.https')
	local ltn_ok, ltn12 = pcall(require, 'ltn12')
	if not ssl_ok or not ltn_ok then
		return nil, 'luasec and luasocket needed for root TA bootstrap'
	end
	local resp = {}
	local r, c, h, s = https.request{
	       url = url,
	       cafile = ca,
	       verify = {'peer', 'fail_if_no_peer_cert' },
	       protocol = 'tlsv1_2',
	       sink = ltn12.sink.table(resp),
	}
	if r == nil then return r, c end
	return resp[1]
end

-- Fetch root anchors in XML over HTTPS, returning a zone-file-style string
-- or false in case of error, and a message.
local function bootstrap(url, ca)
	-- RFC 7958, sec. 2, but we don't do precise XML parsing.
	-- @todo ICANN certificate is verified against current CA
	--       this is not ideal, as it should rather verify .xml signature which
	--       is signed by ICANN long-lived cert, but luasec has no PKCS7
	local xml, err = https_fetch(url, ca)
	if not xml then
		return false, string.format('[ ta ] fetch of "%s" failed: %s', url, err)
	end
	local rr = ''
	-- Parse root trust anchor, one digest at a time, converting to a zone-file-style string.
	string.gsub(xml, "<KeyDigest[^>]*>(.-)</KeyDigest>", function (xml1)
		local fields = {}
		string.gsub(xml1, "<([%w]+).->([^<]+)</[%w]+>", function (k, v) fields[k] = v end)
		rr = rr .. '\n' .. string.format('. 0 IN DS %s %s %s %s',
			fields.KeyTag, fields.Algorithm, fields.DigestType, fields.Digest)
	end)
	if rr == '' then
		return false, string.format('[ ta ] failed to get any record from "%s"', url)
	end
	local msg = '[ ta ] Root trust anchors bootstrapped over https with pinned certificate.\n'
			 .. '       You may want to verify them manually, as described on:\n'
			 .. '       https://data.iana.org/root-anchors/old/draft-icann-dnssec-trust-anchor.html#sigs'
	return rr, msg
-- Load the module
local ffi = require 'ffi'
local C = ffi.C

-- RFC5011 state table
local key_state = {
	Start = 'Start', AddPend = 'AddPend', Valid = 'Valid',
	Missing = 'Missing', Revoked = 'Revoked', Removed = 'Removed'
}

-- Find key in current keyset
local function ta_find(keyset, rr)
	for i, ta in ipairs(keyset) do
		-- Match key owner and content
		if ta.owner == rr.owner then
			if ta.type == rr.type then
				if rr.type == kres.type.DNSKEY then
					if C.kr_dnssec_key_match(ta.rdata, #ta.rdata, rr.rdata, #rr.rdata) == 0 then
						return ta
					end
				elseif rr.type == kres.type.DS and ta.rdata == rr.rdata then
					return ta
				end
			-- DNSKEY superseding DS, inexact match
			elseif rr.type == kres.type.DNSKEY and ta.type == kres.type.DS then
				if ta.key_tag == C.kr_dnssec_key_tag(rr.type, rr.rdata, #rr.rdata) then
					keyset[i] = rr -- Replace current DS
					rr.state = ta.state
					rr.key_tag = ta.key_tag
					return rr
				end
			-- DS key matching DNSKEY, inexact match
			elseif rr.type == kres.type.DS and ta.type == kres.type.DNSKEY then
				local ds_tag = C.kr_dnssec_key_tag(rr.type, rr.rdata, #rr.rdata)
				local dnskey_tag = C.kr_dnssec_key_tag(ta.type, ta.rdata, #ta.rdata)
				if ds_tag == dnskey_tag then
					return ta
-- Evaluate TA status of a RR according to RFC5011
local function ta_present(keyset, rr, hold_down_time, force)
	if rr.type == kres.type.DNSKEY and not C.kr_dnssec_key_ksk(rr.rdata) then
	-- Find the key in current key set and check its status
	local now = os.time()
	local key_revoked = (rr.type == kres.type.DNSKEY) and C.kr_dnssec_key_revoked(rr.rdata)
	local key_tag = C.kr_dnssec_key_tag(rr.type, rr.rdata, #rr.rdata)
	local ta = ta_find(keyset, rr)
	if ta then
		-- Key reappears (KeyPres)
		if ta.state == key_state.Missing then
			ta.state = key_state.Valid
			ta.timer = nil
		end
		-- Key is revoked (RevBit)
		if ta.state == key_state.Valid or ta.state == key_state.Missing then
			if key_revoked then
				ta.state = key_state.Revoked
				ta.timer = now + hold_down_time
			end
		end
		-- Remove hold-down timer expires (RemTime)
		if ta.state == key_state.Revoked and os.difftime(ta.timer, now) <= 0 then
			ta.state = key_state.Removed
			ta.timer = nil
		end
		-- Add hold-down timer expires (AddTime)
		if ta.state == key_state.AddPend and os.difftime(ta.timer, now) <= 0 then
			ta.state = key_state.Valid
			ta.timer = nil
		end
		if rr.state ~= key_state.Valid or verbose() then
			print('[ ta ] key: '..key_tag..' state: '..ta.state)
		end
		return true
	elseif not key_revoked then -- First time seen (NewKey)
		if force then
			rr.state = key_state.Valid
		else
			rr.state = key_state.AddPend
			rr.timer = now + hold_down_time
		end
		if rr.state ~= key_state.Valid or verbose() then
			print('[ ta ] key: '..key_tag..' state: '..rr.state)
		end
		table.insert(keyset, rr)
		return true
	end
	return false
end

-- TA is missing in the new key set
local function ta_missing(ta, hold_down_time)
	-- Key is removed (KeyRem)
	local keep_ta = true
	local key_tag = C.kr_dnssec_key_tag(ta.type, ta.rdata, #ta.rdata)
	if ta.state == key_state.Valid then
		ta.state = key_state.Missing
		ta.timer = os.time() + hold_down_time

	-- Remove key that is missing for too long
	elseif ta.state == key_state.Missing and os.difftime(ta.timer, os.time()) <= 0 then
		ta.state = key_state.Removed
		print('[ ta ] key: '..key_tag..' removed because missing for too long')
		keep_ta = false

	-- Purge pending key
	elseif ta.state == key_state.AddPend then
		print('[ ta ] key: '..key_tag..' purging')
	print('[ ta ] key: '..key_tag..' state: '..ta.state)
local active_refresh -- forward
-- Plan an event for refreshing the root DNSKEYs and re-scheduling itself
local function refresh_plan(trust_anchors, timeout, priming, bootstrap)
	trust_anchors.refresh_ev = event.after(timeout, function (ev)
		resolve('.', kres.type.DNSKEY, kres.class.IN, kres.query.NO_CACHE,
		function (pkt)
			-- Schedule itself with updated timeout
			local next_time = active_refresh(trust_anchors, kres.pkt_t(pkt), bootstrap)
			if trust_anchors.refresh_time ~= nil then
				next_time = trust_anchors.refresh_time
			print('[ ta ] next refresh in ' .. next_time/hour .. ' hours')
			refresh_plan(trust_anchors, next_time)
			-- Priming query, prime root NS next
			if priming ~= nil then
				resolve('.', kres.type.NS, kres.class.IN)
			end
-- Refresh the root DNSKEYs from the packet, and return time to the next check.
active_refresh = function (trust_anchors, pkt, bootstrap)
	local retry = true
	if pkt:rcode() == kres.rcode.NOERROR then
		local records = pkt:section(kres.section.ANSWER)
		local keyset = {}
		for i, rr in ipairs(records) do
			if rr.type == kres.type.DNSKEY then
				table.insert(keyset, rr)
			end
		end
		trust_anchors.update(keyset, bootstrap)
	else
		print('[ ta ] active refresh failed, rcode: '..pkt:rcode())
	end
	-- Calculate refresh/retry timer (RFC 5011, 2.3)
	local min_ttl = retry and day or 15 * day
	for i, rr in ipairs(trust_anchors.keyset) do -- 10 or 50% of the original TTL
		min_ttl = math.min(min_ttl, (retry and 100 or 500) * rr.ttl)
	end
	return math.max(hour, min_ttl)
end

-- Write keyset to a file
local function keyset_write(keyset, path)
	local file = assert(io.open(path..'.lock', 'w'))
	for i = 1, #keyset do
		local ta = keyset[i]
		local rr_str = string.format('%s ; %s\n', kres.rr2str(ta), ta.state)
		if ta.state ~= key_state.Valid and ta.state ~= key_state.Missing then
			rr_str = '; '..rr_str -- Invalidate key string
		end
		file:write(rr_str)
	end
	file:close()
	os.rename(path..'.lock', path)
end

-- TA store management
local trust_anchors = {
	keyset = {},
	insecure = {},
	bootstrap_url = 'https://data.iana.org/root-anchors/root-anchors.xml',
	bootstrap_ca = '@ETCDIR@/icann-ca.pem',

	-- Update existing keyset; return true if successful.
	-- Param `initial` (bool): force .NewKey states to .Valid, i.e. init empty keyset.
	update = function (new_keys, initial)
		if not new_keys then return false end
		-- Filter TAs to be purged from the keyset (KeyRem)
		local hold_down = trust_anchors.hold_down_time / 1000
		local keyset = {}
		local keep_removed = trust_anchors.keep_removed
		for i, ta in ipairs(trust_anchors.keyset) do
			local keep = true
			if not ta_find(new_keys, ta) then
				keep = ta_missing(ta, hold_down)
			-- Purge removed keys
			if ta.state == key_state.Removed then
				if keep_removed > 0 then
					keep_removed = keep_removed - 1
				else
					keep = false
				end
			end
				table.insert(keyset, ta)
		for i, rr in ipairs(new_keys) do
			if (rr.type == kres.type.DNSKEY or rr.type == kres.type.DS) and rr.rdata ~= nil then
				ta_present(keyset, rr, hold_down, initial)
			end
		end
		-- Publish active TAs
		local store = kres.context().trust_anchors
		C.kr_ta_clear(store)
		if next(keyset) == nil then return false end
		for i, ta in ipairs(keyset) do
			-- Key MAY be used as a TA only in these two states (RFC5011, 4.2)
			if ta.state == key_state.Valid or ta.state == key_state.Missing then
				C.kr_ta_add(store, ta.owner, ta.type, ta.ttl, ta.rdata, #ta.rdata)
			end
		end
		trust_anchors.keyset = keyset
		-- Store keyset in the file
		if trust_anchors.file_current ~= nil then
			keyset_write(keyset, trust_anchors.file_current)
		end
		return true
	end,
	-- Load keys from a file (managed)
	config = function (path, unmanaged)
		-- Bootstrap if requested and keyfile doesn't exist
		if trust_anchors.refresh_ev ~= nil then event.cancel(trust_anchors.refresh_ev) end
		if not unmanaged then
			if not io.open(path, 'r') then
				local rr, msg = bootstrap(trust_anchors.bootstrap_url, trust_anchors.bootstrap_ca)
					msg = msg .. '\n'
						.. '[ ta ] Failed to bootstrap root trust anchors; see:\n'
					    .. '       https://knot-resolver.readthedocs.io/en/latest/daemon.html#enabling-dnssec'
					error(msg)
				trustanchor(rr)
				-- Fetch DNSKEY immediately
				trust_anchors.file_current = path
				refresh_plan(trust_anchors, 0, true, true)
				return
			elseif path == trust_anchors.file_current then
				return
		-- Parse new keys, refresh eventually
		local new_keys = require('zonefile').file(path)
		if unmanaged then
			trust_anchors.file_current = nil
		else
			trust_anchors.file_current = path
		end
		if trust_anchors.update(new_keys, true) then
			refresh_plan(trust_anchors, 10 * sec, true, false)
	end,
	-- Add DS/DNSKEY record(s) (unmanaged)
	add = function (keystr)
		return trustanchor(keystr)
	end,
	-- Negative TA management
	set_insecure = function (list)
		local store = kres.context().negative_anchors
		C.kr_ta_clear(store)
		for i = 1, #list do
			local dname = kres.str2dname(list[i])
			C.kr_ta_add(store, dname, kres.type.DS, 0, nil, 0)
		end
		trust_anchors.insecure = list
	end,
}

-- Syntactic sugar for TA store
setmetatable(trust_anchors, {
	__newindex = function (t,k,v)
	if     k == 'file' then t.config(v)
	elseif k == 'negative' then t.set_insecure(v)
	else   rawset(t, k, v) end
	end,
})

return trust_anchors