diff options
Diffstat (limited to 'daemon/lua/trust_anchors.lua.in')
-rw-r--r-- | daemon/lua/trust_anchors.lua.in | 532 |
1 files changed, 532 insertions, 0 deletions
diff --git a/daemon/lua/trust_anchors.lua.in b/daemon/lua/trust_anchors.lua.in new file mode 100644 index 0000000..56a7f95 --- /dev/null +++ b/daemon/lua/trust_anchors.lua.in @@ -0,0 +1,532 @@ +-- 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 |