-- 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]+).->([^<]+)", 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, '', 1) assert_str_match(xml, '.', 1) assert_str_match(xml, '', 1) -- Parse root trust anchor, one digest at a time, converting to a zone-file-style string. local keydigests = {} string.gsub(xml, "]*)>(.-)", 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