diff options
Diffstat (limited to 'modules/ta_update/ta_update.lua')
-rw-r--r-- | modules/ta_update/ta_update.lua | 349 |
1 files changed, 349 insertions, 0 deletions
diff --git a/modules/ta_update/ta_update.lua b/modules/ta_update/ta_update.lua new file mode 100644 index 0000000..2361e16 --- /dev/null +++ b/modules/ta_update/ta_update.lua @@ -0,0 +1,349 @@ +-- SPDX-License-Identifier: GPL-3.0-or-later +-- Module interface +local ffi = require('ffi') +local kres = require('kres') +local C = ffi.C + +assert(trust_anchors, 'ta_update module depends on initialized trust_anchors library') +local key_state = trust_anchors.key_state +assert(key_state) + +local ta_update = {} +local tracked_tas = {} -- zone name (wire) => {event = number} + + +-- Find key in current keyset +local function ta_find(keyset, rr) + local rr_tag = C.kr_dnssec_key_tag(rr.type, rr.rdata, #rr.rdata) + if rr_tag < 0 or rr_tag > 65535 then + log_warn(ffi.C.LOG_GRP_TAUPDATE, string.format('ignoring invalid or unsupported RR: %s: %s', + kres.rr2str(rr), ffi.string(C.knot_strerror(rr_tag)))) + return nil + end + for i, ta in ipairs(keyset) do + -- Match key owner and content + local ta_tag = C.kr_dnssec_key_tag(ta.type, ta.rdata, #ta.rdata) + if ta_tag < 0 or ta_tag > 65535 then + log_warn(ffi.C.LOG_GRP_TAUPDATE, string.format('[ta_update] ignoring invalid or unsupported RR: %s: %s', + kres.rr2str(ta), ffi.string(C.knot_strerror(ta_tag)))) + else + 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 == rr_tag 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 + if rr_tag == ta_tag then + return ta + end + end + end + end + end + return nil +end + +-- Evaluate TA status of a RR according to RFC5011. The time is in seconds. +local function ta_present(keyset, rr, hold_down_time) + if rr.type == kres.type.DNSKEY and not C.kr_dnssec_key_ksk(rr.rdata) then + return false -- Ignore + end + -- Attempt to extract key_tag + local key_tag = C.kr_dnssec_key_tag(rr.type, rr.rdata, #rr.rdata) + if key_tag < 0 or key_tag > 65535 then + log_warn(ffi.C.LOG_GRP_TAUPDATE, string.format('[ta_update] ignoring invalid or unsupported RR: %s: %s', + kres.rr2str(rr), ffi.string(C.knot_strerror(key_tag)))) + return false + end + -- 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 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 then + log_info(ffi.C.LOG_GRP_TAUPDATE, 'key: ' .. key_tag .. ' state: '..ta.state) + end + return true + elseif not key_revoked then -- First time seen (NewKey) + rr.state = key_state.AddPend + rr.key_tag = key_tag + rr.timer = now + hold_down_time + table.insert(keyset, rr) + return false + end +end + +-- TA is missing in the new key set. The time is in seconds. +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 key_tag < 0 or key_tag > 65535 then + log_warn(ffi.C.LOG_GRP_TAUPDATE, string.format('[ta_update] ignoring invalid or unsupported RR: %s: %s', + kres.rr2str(ta), ffi.string(C.knot_strerror(key_tag)))) + key_tag = '' + end + 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 + log_info(ffi.C.LOG_GRP_TAUPDATE, 'key: '..key_tag..' removed because missing for too long') + keep_ta = false + + -- Purge pending key + elseif ta.state == key_state.AddPend then + log_info(ffi.C.LOG_GRP_TAUPDATE, 'key: '..key_tag..' purging') + keep_ta = false + end + log_info(ffi.C.LOG_GRP_TAUPDATE, 'key: '..key_tag..' state: '..ta.state) + return keep_ta +end + +-- Update existing keyset; return true if successful. +local function update(keyset, new_keys) + if not new_keys then return false end + if not keyset.managed then + -- this may happen due to race condition during testing in CI (refresh time < query time) + return false + end + + -- Filter TAs to be purged from the keyset (KeyRem), in three steps + -- 1: copy TAs to be kept to `keepset` + local hold_down = (keyset.hold_down_time or ta_update.hold_down_time) / 1000 + local keepset = {} + local keep_removed = keyset.keep_removed or ta_update.keep_removed + for _, ta in ipairs(keyset) do + local keep = true + if not ta_find(new_keys, ta) then + -- Ad-hoc: RFC 5011 doesn't mention removing a Missing key. + -- Let's do it after a very long period has elapsed. + keep = ta_missing(ta, hold_down * 4) + end + -- 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 + if keep then + table.insert(keepset, ta) + end + end + -- 2: remove all TAs - other settings etc. will remain in the keyset + for i, _ in ipairs(keyset) do + keyset[i] = nil + end + -- 3: move TAs to be kept into the keyset (same indices) + for k, ta in pairs(keepset) do + keyset[k] = ta + end + + -- Evaluate new TAs + for _, 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) + end + end + + -- Store the keyset + trust_anchors.keyset_write(keyset) + + -- Start using the new TAs. + if not trust_anchors.keyset_publish(keyset) then + -- TODO: try to rebootstrap if for root? + return false + else + log_debug(ffi.C.LOG_GRP_TAUPDATE, 'refreshed trust anchors for domain ' .. kres.dname2str(keyset.owner) .. ' are:\n' + .. trust_anchors.summary(keyset.owner)) + end + + return true +end + +local function unmanagedkey_change(file_name) + log_warn(ffi.C.LOG_GRP_TAUPDATE, 'you need to update package with trust anchors in "%s" before it breaks', file_name) +end + +local function check_upstream(keyset, new_keys) + local process_keys = {} + + for _, rr in ipairs(new_keys) do + local key_revoked = (rr.type == kres.type.DNSKEY) and C.kr_dnssec_key_revoked(rr.rdata) + local ta = ta_find(keyset, rr) + table.insert(process_keys, ta) + + if rr.type == kres.type.DNSKEY and not C.kr_dnssec_key_ksk(rr.rdata) then + goto continue -- Ignore + end + + if not ta and not key_revoked then + -- I see new key + ta_update.cb_unmanagedkey_change(keyset.filename) + end + + if ta and key_revoked then + -- I see revoked key + ta_update.cb_unmanagedkey_change(keyset.filename) + end + + ::continue:: + end + + for _, rr in ipairs(keyset) do + local missing_rr = true + for _, rr_old in ipairs(process_keys) do + if (rr.owner == rr_old.owner) and (rr.type == rr_old.type) and (rr.type == kres.type.DNSKEY) then + if C.kr_dnssec_key_match(rr.rdata, #rr.rdata, rr_old.rdata, #rr_old.rdata) == 0 then + missing_rr = false + break + end + end + end + + if missing_rr then + -- This key is missing in the new keyset + ta_update.cb_unmanagedkey_change(keyset.filename) + end + end + +end + +-- Refresh the DNSKEYs from the packet, and return time to the next check. +local function active_refresh(keyset, pkt, req, managed) + local retry = true + + if pkt ~= nil and pkt:rcode() == kres.rcode.NOERROR then + local records = pkt:section(kres.section.ANSWER) + local new_keys = {} + for _, rr in ipairs(records) do + if rr.type == kres.type.DNSKEY then + table.insert(new_keys, rr) + end + end + + if managed then + update(keyset, new_keys) + else + check_upstream(keyset, new_keys) + end + retry = false + else + local qry = req:initial() + if qry.flags.DNSSEC_BOGUS == true then + log_warn(ffi.C.LOG_GRP_TAUPDATE, 'active refresh failed, update your trust anchors in "%s"', keyset.filename) + elseif pkt == nil then + log_warn(ffi.C.LOG_GRP_TAUPDATE, 'active refresh failed, answer was dropped') + else + log_warn(ffi.C.LOG_GRP_TAUPDATE, 'active refresh failed for ' .. kres.dname2str(keyset.owner) + .. ' with rcode: ' .. pkt:rcode()) + end + end + -- Calculate refresh/retry timer (RFC 5011, 2.3) + local min_ttl = retry and day or 15 * day + for _, rr in ipairs(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 + +-- Plan an event for refreshing DNSKEYs and re-scheduling itself +local function refresh_plan(keyset, delay, managed) + local owner = keyset.owner + local owner_str = kres.dname2str(keyset.owner) + if not tracked_tas[owner] then + tracked_tas[owner] = {} + end + local track_cfg = tracked_tas[owner] + if track_cfg.event then -- restart timer if necessary + event.cancel(track_cfg.event) + end + track_cfg.event = event.after(delay, function () + log_info(ffi.C.LOG_GRP_TAUPDATE, 'refreshing TA for ' .. owner_str) + resolve(owner_str, kres.type.DNSKEY, kres.class.IN, 'NO_CACHE', + function (pkt, req) + -- Schedule itself with updated timeout + local delay_new = active_refresh(keyset, pkt, req, managed) + delay_new = keyset.refresh_time or ta_update.refresh_time or delay_new + log_info(ffi.C.LOG_GRP_TAUPDATE, 'next refresh for ' .. owner_str .. ' in ' + .. delay_new/hour .. ' hours') + refresh_plan(keyset, delay_new, managed) + end) + end) +end + +ta_update = { + -- [optional] overrides for global defaults of + -- hold_down_time, refresh_time, keep_removed + hold_down_time = 30 * day, + refresh_time = nil, + keep_removed = 0, + tracked = tracked_tas, -- debug and visibility, should not be changed by hand + cb_unmanagedkey_change = unmanagedkey_change, +} + +-- start tracking (already loaded) TA with given zone name in wire format +-- do first refresh immediately +function ta_update.start(zname, managed) + local keyset = trust_anchors.keysets[zname] + if not keyset then + panic('[ta_update] TA must be configured first before tracking it') + end + refresh_plan(keyset, 0, managed) +end + +function ta_update.stop(zname) + if tracked_tas[zname] then + event.cancel(tracked_tas[zname].event) + tracked_tas[zname] = nil + trust_anchors.keysets[zname].managed = false + end +end + +-- stop all timers +function ta_update.deinit() + for zname, _ in pairs(tracked_tas) do + ta_update.stop(zname) + end +end + +return ta_update |