532 lines
16 KiB
Lua
532 lines
16 KiB
Lua
-- SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
-- Load the module
|
|
local ffi = require 'ffi'
|
|
local kres = require('kres')
|
|
local C = ffi.C
|
|
|
|
local trust_anchors -- the public pseudo-module, exported as global variable
|
|
|
|
-- RFC5011 state table
|
|
local key_state = {
|
|
Start = 'Start', AddPend = 'AddPend', Valid = 'Valid',
|
|
Missing = 'Missing', Revoked = 'Revoked', Removed = 'Removed'
|
|
}
|
|
|
|
local function upgrade_required(msg)
|
|
if msg then
|
|
msg = msg .. '\n'
|
|
else
|
|
msg = ''
|
|
end
|
|
panic('Configuration upgrade required: ' .. msg .. 'Please refer to ' ..
|
|
'https://knot-resolver.readthedocs.io/en/stable/upgrading.html')
|
|
end
|
|
|
|
-- TODO: Move bootstrap to a separate module or even its own binary
|
|
-- remove UTC timezone specification if present or throw error
|
|
local function time2utc(orig_timespec)
|
|
local patterns = {'[+-]00:00$', 'Z$'}
|
|
for _, pattern in ipairs(patterns) do
|
|
local timespec, removals = string.gsub(orig_timespec, pattern, '')
|
|
if removals == 1 then
|
|
return timespec
|
|
end
|
|
end
|
|
error(string.format('unsupported time specification: %s', orig_timespec))
|
|
end
|
|
|
|
local function keydigest_is_valid(valid_from, valid_until)
|
|
local format = '%Y-%m-%dT%H:%M:%S'
|
|
local time_now = os.date('!%Y-%m-%dT%H:%M:%S') -- ! forces UTC
|
|
local time_diff = ffi.new('double[1]')
|
|
local err = ffi.C.kr_strptime_diff(
|
|
format, time_now, time2utc(valid_from), time_diff)
|
|
if (err ~= nil) then
|
|
error(string.format('failed to process "validFrom" constraint: %s',
|
|
ffi.string(err)))
|
|
end
|
|
local from_ok = time_diff[0] > 0
|
|
|
|
-- optional attribute
|
|
local until_ok = true
|
|
if valid_until then
|
|
err = ffi.C.kr_strptime_diff(
|
|
format, time_now, time2utc(valid_until), time_diff)
|
|
if (err ~= nil) then
|
|
error(string.format('failed to process "validUntil" constraint: %s',
|
|
ffi.string(err)))
|
|
end
|
|
until_ok = time_diff[0] < 0
|
|
end
|
|
return from_ok and until_ok
|
|
end
|
|
|
|
local function parse_xml_keydigest(attrs, inside, output)
|
|
local fields = {}
|
|
local _, n = string.gsub(attrs, "([%w]+)=\"([^\"]*)\"", function (k, v) fields[k] = v end)
|
|
assert(n >= 1,
|
|
string.format('cannot parse XML attributes from "%s"', attrs))
|
|
assert(fields['validFrom'],
|
|
string.format('mandatory KeyDigest XML attribute validFrom ' ..
|
|
'not found in "%s"', attrs))
|
|
local valid_attrs = {id = true, validFrom = true, validUntil = true}
|
|
for key, _ in pairs(fields) do
|
|
assert(valid_attrs[key],
|
|
string.format('unsupported KeyDigest attribute "%s" found in "%s"',
|
|
key, attrs))
|
|
end
|
|
|
|
_, n = string.gsub(inside, "<([%w]+).->([^<]+)</[%w]+>", function (k, v) fields[k] = v end)
|
|
assert(n >= 1,
|
|
string.format('error parsing KeyDigest XML elements from "%s"',
|
|
inside))
|
|
local mandatory_elements = {'KeyTag', 'Algorithm', 'DigestType', 'Digest'}
|
|
for _, key in ipairs(mandatory_elements) do
|
|
assert(fields[key],
|
|
string.format('mandatory element %s is missing in "%s"',
|
|
key, inside))
|
|
end
|
|
assert(n == 4, string.format('found %d elements but expected 4 in %s', n, inside))
|
|
table.insert(output, fields) -- append to list of parsed keydigests
|
|
end
|
|
|
|
local function generate_ds(keydigests)
|
|
local rrset = ''
|
|
for _, fields in ipairs(keydigests) do
|
|
local rr = string.format(
|
|
'. 0 IN DS %s %s %s %s',
|
|
fields.KeyTag, fields.Algorithm, fields.DigestType, fields.Digest)
|
|
if keydigest_is_valid(fields['validFrom'], fields['validUntil']) then
|
|
rrset = rrset .. '\n' .. rr
|
|
else
|
|
log_info(ffi.C.LOG_GRP_TA, 'skipping trust anchor "%s" ' ..
|
|
'because it is outside of validity range', rr)
|
|
end
|
|
end
|
|
return rrset
|
|
end
|
|
|
|
local function assert_str_match(str, pattern, expected)
|
|
local count = 0
|
|
for _ in string.gmatch(str, pattern) do
|
|
count = count + 1
|
|
end
|
|
assert(count == expected,
|
|
string.format('expected %d occurences of "%s" but got %d in "%s"',
|
|
expected, pattern, count, str))
|
|
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)
|
|
local kluautil = require('kluautil')
|
|
local file = io.tmpfile()
|
|
-- 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 rcode, errmsg = kluautil.kr_https_fetch(url, file, ca)
|
|
if rcode == nil then
|
|
file:close()
|
|
return false, string.format('[ ta ] fetch of "%s" failed: %s', url, errmsg)
|
|
end
|
|
|
|
local xml = file:read("*a")
|
|
file:close()
|
|
|
|
-- we support only minimal subset of https://tools.ietf.org/html/rfc7958
|
|
assert_str_match(xml, '<?xml version="1%.0" encoding="UTF%-8"%?>', 1)
|
|
assert_str_match(xml, '<TrustAnchor ', 1)
|
|
assert_str_match(xml, '<Zone>.</Zone>', 1)
|
|
assert_str_match(xml, '</TrustAnchor>', 1)
|
|
|
|
-- Parse root trust anchor, one digest at a time, converting to a zone-file-style string.
|
|
local keydigests = {}
|
|
string.gsub(xml, "<KeyDigest([^>]*)>(.-)</KeyDigest>", function(attrs, inside)
|
|
parse_xml_keydigest(attrs, inside, keydigests)
|
|
end)
|
|
local rrset = generate_ds(keydigests)
|
|
if rrset == '' then
|
|
return false, string.format('[ ta ] no valid trust anchors found at "%s"', url)
|
|
end
|
|
local msg = '[ ta ] Root trust anchors bootstrapped over https with pinned certificate.\n'
|
|
.. ' You SHOULD verify them manually against original source:\n'
|
|
.. ' https://www.iana.org/dnssec/files\n'
|
|
.. '[ ta ] Bootstrapped root trust anchors are:'
|
|
.. rrset
|
|
return rrset, msg
|
|
end
|
|
|
|
local function bootstrap_write(rrstr, filename)
|
|
local fname_tmp = filename .. '.lock.' .. tostring(worker.pid);
|
|
local file = assert(io.open(fname_tmp, 'w'))
|
|
file:write(rrstr)
|
|
file:close()
|
|
assert(os.rename(fname_tmp, filename))
|
|
end
|
|
-- Bootstrap end
|
|
|
|
-- Update ta.comment and return decorated line representing the RR
|
|
-- This is meant to be in zone-file format.
|
|
local function ta_rr_str(ta)
|
|
ta.comment = ' ' .. ta.state .. ':' .. (ta.timer or '')
|
|
.. ' ; KeyTag:' .. ta.key_tag -- the tag is just for humans
|
|
local rr_str = kres.rr2str(ta) .. '\n'
|
|
if ta.state ~= key_state.Valid and ta.state ~= key_state.Missing then
|
|
rr_str = '; '..rr_str -- Invalidate key string (for older kresd versions)
|
|
end
|
|
return rr_str
|
|
end
|
|
|
|
-- Write keyset to a file. States and timers are stored in comments.
|
|
local function keyset_write(keyset)
|
|
if not keyset.managed then -- not to be persistent, this is an error!
|
|
panic('internal error: keyset_write called for an unmanaged TA')
|
|
end
|
|
local fname_tmp = keyset.filename .. '.lock.' .. tostring(worker.pid);
|
|
local file = assert(io.open(fname_tmp, 'w'))
|
|
for i = 1, #keyset do
|
|
file:write(ta_rr_str(keyset[i]))
|
|
end
|
|
file:close()
|
|
assert(os.rename(fname_tmp, keyset.filename))
|
|
end
|
|
|
|
-- Search the values of a table and return the corresponding key (or nil).
|
|
local function table_search(t, val)
|
|
for k, v in pairs(t) do
|
|
if v == val then
|
|
return k
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- For each RR, parse .state and .timer from .comment.
|
|
local function keyset_parse_comments(tas, default_state)
|
|
for _, ta in pairs(tas) do
|
|
ta.state = default_state
|
|
if ta.comment then
|
|
string.gsub(ta.comment, '^%s*(%a+):(%d*)', function (state, time)
|
|
if table_search(key_state, state) then
|
|
ta.state = state
|
|
end
|
|
ta.timer = tonumber(time) -- nil on failure
|
|
end)
|
|
ta.comment = nil
|
|
end
|
|
end
|
|
return tas
|
|
end
|
|
|
|
-- Read keyset from a file xor a string. (This includes the key states and timers.)
|
|
local function keyset_read(path, str)
|
|
if (path == nil) == (str == nil) then -- exactly one of them must be nil
|
|
return nil, "internal ERROR: incorrect call to TA's keyset_read"
|
|
end
|
|
-- First load the regular entries, trusting them.
|
|
local zonefile = require('zonefile')
|
|
local tas, err
|
|
if path ~= nil then
|
|
tas, err = zonefile.file(path)
|
|
else
|
|
tas, err = zonefile.string(str)
|
|
end
|
|
if not tas then
|
|
return tas, err
|
|
end
|
|
keyset_parse_comments(tas, key_state.Valid)
|
|
|
|
-- The untrusted keys are commented out but important to load.
|
|
local line_iter
|
|
if path ~= nil then
|
|
line_iter = io.lines(path)
|
|
else
|
|
line_iter = string.gmatch(str, "[^\n]+")
|
|
end
|
|
for line in line_iter do
|
|
if line:sub(1, 2) == '; ' then
|
|
-- Ignore the line if it fails to parse including recognized .state.
|
|
local l_set = zonefile.string(line:sub(3))
|
|
if l_set and l_set[1] then
|
|
keyset_parse_comments(l_set)
|
|
if l_set[1].state then
|
|
table.insert(tas, l_set[1])
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Fill tas[*].key_tag
|
|
for _, ta in pairs(tas) do
|
|
local ta_keytag = C.kr_dnssec_key_tag(ta.type, ta.rdata, #ta.rdata)
|
|
if not (ta_keytag >= 0 and ta_keytag <= 65535) then
|
|
return nil, string.format('invalid key: "%s": %s',
|
|
kres.rr2str(ta), ffi.string(C.knot_strerror(ta_keytag)))
|
|
end
|
|
ta.key_tag = ta_keytag
|
|
end
|
|
|
|
-- Fill tas.owner
|
|
if not tas[1] then
|
|
return nil, "empty TA set"
|
|
end
|
|
local owner = tas[1].owner
|
|
for _, ta in ipairs(tas) do
|
|
if ta.owner ~= owner then
|
|
return nil, string.format("do not mix %s and %s TAs in single file/string",
|
|
kres.dname2str(ta.owner), kres.dname2str(owner))
|
|
end
|
|
end
|
|
tas.owner = owner
|
|
|
|
return tas
|
|
end
|
|
|
|
-- Replace current TAs for given owner by the "trusted" ones from passed keyset.
|
|
-- Return true iff no TA errored out and at least one is in VALID state.
|
|
local function keyset_publish(keyset)
|
|
local store = kres.context().trust_anchors
|
|
local count = 0
|
|
local has_error = false
|
|
C.kr_ta_del(store, keyset.owner)
|
|
for _, 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
|
|
if C.kr_ta_add(store, ta.owner, ta.type, ta.ttl, ta.rdata, #ta.rdata) == 0 then
|
|
count = count + 1
|
|
else
|
|
ta.state = 'ERROR'
|
|
has_error = true
|
|
end
|
|
end
|
|
end
|
|
if count == 0 then
|
|
log_error(ffi.C.LOG_GRP_TA, 'ERROR: no anchors are trusted for ' ..
|
|
kres.dname2str(keyset.owner) .. ' !')
|
|
end
|
|
return count > 0 and not has_error
|
|
end
|
|
|
|
local function add_file(path, unmanaged)
|
|
local managed = not unmanaged
|
|
if not ta_update then
|
|
modules.load('ta_update')
|
|
end
|
|
if managed then
|
|
if not io.open(path .. '.lock', 'w') then
|
|
error("[ ta ] ERROR: write access needed to keyfile dir '"..path.."'")
|
|
end
|
|
os.remove(path .. ".lock")
|
|
end
|
|
|
|
-- Bootstrap TA for root zone if keyfile doesn't exist
|
|
if managed and not io.open(path, 'r') then
|
|
if trust_anchors.keysets['\0'] then
|
|
error(string.format(
|
|
"[ ta ] keyfile '%s' doesn't exist and root key is already installed, "
|
|
.. "cannot bootstrap; provide a path to valid file with keys", path))
|
|
end
|
|
log_info(ffi.C.LOG_GRP_TA, "keyfile '%s': doesn't exist, bootstrapping", path);
|
|
local rrstr, msg = bootstrap(trust_anchors.bootstrap_url, trust_anchors.bootstrap_ca)
|
|
if not rrstr then
|
|
msg = msg .. '\n'
|
|
.. '[ ta ] Failed to bootstrap root trust anchors!'
|
|
error(msg)
|
|
end
|
|
print(msg)
|
|
bootstrap_write(rrstr, path)
|
|
-- continue as if the keyfile was there
|
|
end
|
|
|
|
-- Parse the file and check its sanity
|
|
local keyset, err = keyset_read(path)
|
|
if not keyset then
|
|
panic("[ ta ] ERROR: failed to read anchors from '%s' (%s)", path, err)
|
|
end
|
|
keyset.filename = path
|
|
keyset.managed = managed
|
|
|
|
local owner = keyset.owner
|
|
local owner_str = kres.dname2str(owner)
|
|
local keyset_orig = trust_anchors.keysets[owner]
|
|
if keyset_orig then
|
|
log_warn(ffi.C.LOG_GRP_TA, 'warning: overriding previously set trust anchors for ' .. owner_str)
|
|
if keyset_orig.managed and ta_update then
|
|
ta_update.stop(owner)
|
|
end
|
|
end
|
|
trust_anchors.keysets[owner] = keyset
|
|
|
|
-- Replace the TA store used for validation
|
|
if keyset_publish(keyset) then
|
|
log_info(ffi.C.LOG_GRP_TA, 'installed trust anchors for domain ' .. owner_str .. ' are:\n'
|
|
.. trust_anchors.summary(owner))
|
|
end
|
|
-- TODO: if failed and for root, try to rebootstrap?
|
|
|
|
ta_update.start(owner, managed)
|
|
end
|
|
|
|
local function remove(zname)
|
|
local owner = kres.str2dname(zname)
|
|
if not trust_anchors.keysets[owner] then
|
|
return false
|
|
end
|
|
|
|
if ta_update then
|
|
ta_update.stop(owner)
|
|
end
|
|
trust_anchors.keysets[owner] = nil
|
|
local store = kres.context().trust_anchors
|
|
C.kr_ta_del(store, owner)
|
|
return true
|
|
end
|
|
|
|
local function ta_str(owner)
|
|
local owner_str = kres.dname2str(owner) .. ' '
|
|
local msg = ''
|
|
for _, nta in pairs(trust_anchors.insecure) do
|
|
if owner == kres.str2dname(nta) then
|
|
msg = owner_str .. 'is negative trust anchor\n'
|
|
end
|
|
end
|
|
if not trust_anchors.keysets[owner] then
|
|
if #msg > 0 then -- it is normal that NTA does not have explicit TA
|
|
return msg
|
|
else
|
|
return owner_str .. 'has no explicit trust anchors\n'
|
|
end
|
|
end
|
|
if #msg > 0 then
|
|
msg = msg .. 'WARNING! negative trust anchor also has an explicit TA\n'
|
|
end
|
|
for _, ta in ipairs(trust_anchors.keysets[owner]) do
|
|
msg = msg .. ta_rr_str(ta)
|
|
end
|
|
return msg
|
|
end
|
|
|
|
-- TA store management, for user docs see ../README.rst
|
|
trust_anchors = {
|
|
-- [internal] table indexed by dname;
|
|
-- each item is a list of RRs and additionally contains:
|
|
-- - owner - that dname (for simplicity)
|
|
-- - [optional] filename in which to persist the state,
|
|
-- implying unmanaged TA if nil
|
|
-- The RR tables also contain some additional TA-specific fields.
|
|
keysets = {},
|
|
|
|
-- Documented properties:
|
|
insecure = {},
|
|
|
|
bootstrap_url = 'https://data.iana.org/root-anchors/root-anchors.xml',
|
|
bootstrap_ca = '@etc_dir@/icann-ca.pem',
|
|
|
|
-- Load keys from a file, 5011-managed by default.
|
|
-- If managed and the file doesn't exist, try bootstrapping the root into it.
|
|
add_file = add_file,
|
|
config = function() upgrade_required('trust_anchors.config was removed, use trust_anchors.add_file()') end,
|
|
remove = remove,
|
|
|
|
keyset_publish = keyset_publish,
|
|
keyset_write = keyset_write,
|
|
key_state = key_state,
|
|
|
|
-- Add DS/DNSKEY record(s) (unmanaged)
|
|
add = function (keystr)
|
|
local keyset, err = keyset_read(nil, keystr)
|
|
if keyset ~= nil then
|
|
local owner = keyset.owner
|
|
local owner_str = kres.dname2str(owner)
|
|
local keyset_orig = trust_anchors.keysets[owner]
|
|
-- Set up trust_anchors.keysets[owner]
|
|
if keyset_orig then
|
|
if keyset_orig.managed then
|
|
panic('[ ta ] it is impossible to add an unmanaged TA for zone '
|
|
.. owner_str .. ' which already has a managed TA')
|
|
end
|
|
log_warn(ffi.C.LOG_GRP_TA, 'warning: extending previously set trust anchors for '
|
|
.. owner_str)
|
|
for _, ta in ipairs(keyset) do
|
|
table.insert(keyset_orig, ta)
|
|
end
|
|
end
|
|
-- Replace the TA store used for validation
|
|
if not keyset_publish(keyset) then
|
|
err = "when publishing the TA set"
|
|
-- trust_anchors.keysets[owner] was already updated to the
|
|
-- (partially) failing state, but I'm not sure how much to improve this
|
|
end
|
|
keyset.managed = false
|
|
trust_anchors.keysets[owner] = keyset
|
|
|
|
end
|
|
log_info(ffi.C.LOG_GRP_TA, 'New TA state:\n' .. trust_anchors.summary())
|
|
if err then
|
|
panic('[ ta ] .add() failed: ' .. err)
|
|
end
|
|
end,
|
|
|
|
-- Negative TA management
|
|
set_insecure = function (list)
|
|
assert(type(list) == 'table', 'parameter must be list of domain names (e.g. {"a.test", "b.example"})')
|
|
local store = kres.context().negative_anchors
|
|
for i = 1, #list do
|
|
local dname = kres.str2dname(list[i])
|
|
if trust_anchors.keysets[dname] then
|
|
error('cannot add NTA '..list[i]..' because it is TA. Use trust_anchors.remove() instead')
|
|
end
|
|
end
|
|
|
|
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,
|
|
-- Return textual representation of all TAs (incl. negative)
|
|
-- It's meant for human consumption.
|
|
summary = function (single_owner)
|
|
if single_owner then -- single domain
|
|
return ta_str(single_owner)
|
|
end
|
|
|
|
-- all domains
|
|
local msg = ''
|
|
local ta_count = 0
|
|
local seen = {}
|
|
for _, nta_str in pairs(trust_anchors.insecure) do
|
|
local owner = kres.str2dname(nta_str)
|
|
seen[owner] = true
|
|
msg = msg .. ta_str(owner)
|
|
end
|
|
for owner, _ in pairs(trust_anchors.keysets) do
|
|
if not seen[owner] then
|
|
ta_count = ta_count + 1
|
|
msg = msg .. ta_str(owner)
|
|
end
|
|
end
|
|
if ta_count == 0 then
|
|
msg = msg .. 'No valid trust anchors, DNSSEC validation is disabled\n'
|
|
end
|
|
return msg
|
|
end,
|
|
}
|
|
|
|
-- Syntactic sugar for TA store
|
|
setmetatable(trust_anchors, {
|
|
__newindex = function (t,k,v)
|
|
if k == 'file' then
|
|
upgrade_required('trust_anchors.file was removed, use trust_anchors.add_file()')
|
|
elseif k == 'negative' then
|
|
upgrade_required('trust_anchors.negative was removed, use trust_anchors.set_insecure()')
|
|
elseif k == 'keyfile_default' then
|
|
upgrade_required('trust_anchors.keyfile_default is now compiled in, see trust_anchors.remove()')
|
|
else rawset(t, k, v) end
|
|
end,
|
|
})
|
|
|
|
return trust_anchors
|