summaryrefslogtreecommitdiffstats
path: root/modules/ta_update/ta_update.lua
blob: 2361e16726317e3d89ea89cd9eff47b3aba243e9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
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