@consiliency/agent-board-schema 1.50.0 → 1.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fixtures/already-applied.json +4 -0
- package/migrations/20261002000100_agent_board_session_challenge_supersede.sql +383 -0
- package/package.json +1 -1
- package/schema-manifest.json +12 -3
- package/src/tooling.test.mjs +10 -2
- package/tests/agent_board_fk_lock_edges.test.sql +20 -0
- package/tests/agent_board_signing_challenge_pending_cap.test.sql +391 -36
- package/tests/isolation/expected/challenge_supersede_lock_order.out +213 -0
- package/tests/isolation/specs/challenge_supersede_lock_order.spec +183 -0
|
@@ -1,23 +1,71 @@
|
|
|
1
|
-
--
|
|
2
|
-
--
|
|
3
|
-
--
|
|
1
|
+
-- issue_signing_key_challenge bounds a key to N=16 concurrently-pending challenges,
|
|
2
|
+
-- and AT the bound it supersedes the oldest pending challenge instead of refusing.
|
|
3
|
+
--
|
|
4
|
+
-- WHY THIS FILE CHANGED SHAPE (F1, code-review-2026-09-01). It used to assert that the
|
|
5
|
+
-- 17th pending challenge was REJECTED. That rejection was the defect: /session step
|
|
6
|
+
-- "challenge" is verify_jwt=false and needs only a directory-visible handle and key id,
|
|
7
|
+
-- so sixteen requests denied a key every further challenge, and the client stopped its
|
|
8
|
+
-- refresh loop on the resulting 403. The bound is unchanged and still asserted; what is
|
|
9
|
+
-- asserted now is that arrival is never refused.
|
|
10
|
+
--
|
|
11
|
+
-- It also stopped standing on `select pass(...)`. Three of this file's four assertions
|
|
12
|
+
-- were unconditional passes placed after a DO block that raised on failure — an ok line
|
|
13
|
+
-- that says nothing about what it ran beside, and the exact construct review finding F51
|
|
14
|
+
-- names. Every assertion below reads state back out of the table.
|
|
15
|
+
--
|
|
16
|
+
-- WHY EVERY `DO` BLOCK HERE TRAPS INSTEAD OF RAISING, which is the part that is easy to
|
|
17
|
+
-- undo. Rewriting the passes into real assertions is only half the job, and the half that
|
|
18
|
+
-- is visible. The other half was found by MUTATING: reverting the supersede so the RPC
|
|
19
|
+
-- raises `challenge_pending_cap` again made this file report NOTHING. A bare call to a
|
|
20
|
+
-- raising RPC inside a `DO` block aborts the whole transaction, so pgTAP printed `1..18`,
|
|
21
|
+
-- then `ok 1`, `ok 2`, `ok 3`, then twenty lines of `current transaction is aborted` — and
|
|
22
|
+
-- ZERO `not ok`. Three of eighteen assertions ran and nothing said so. That is AGENTS.md
|
|
23
|
+
-- rule 3 exactly, and it is worse than it sounds: the shipped code path never raises, so
|
|
24
|
+
-- this only appears when someone mutates, and a `grep not ok` over the failing run comes
|
|
25
|
+
-- back clean.
|
|
26
|
+
--
|
|
27
|
+
-- So every block that calls the RPC wraps it in `begin … exception when others then …`
|
|
28
|
+
-- and records the OUTCOME (an id, or `sqlerrm`) into the `pcap_state` temp table, which a
|
|
29
|
+
-- named assertion then checks. A raise becomes a value, not an abort. With the trap in
|
|
30
|
+
-- place the same mutation emits 20 of 20 with a named `not ok`, the first being #4.
|
|
31
|
+
--
|
|
32
|
+
-- "Every block" is now literally true, and was not when first written: the SEED loop below
|
|
33
|
+
-- called the RPC sixteen times bare. It happens not to raise under either mutation this
|
|
34
|
+
-- file is cited for (sixteen issues are UNDER the cap), so nothing caught it — which is
|
|
35
|
+
-- the point. A block that cannot raise TODAY is one cap constant away from being the block
|
|
36
|
+
-- that silently stops the file, and "every" in a comment is a claim, not a description.
|
|
37
|
+
--
|
|
38
|
+
-- Two smaller traps in the same family, both of which abort identically and both of which
|
|
39
|
+
-- cost a debugging round here:
|
|
40
|
+
-- * `v_c := null` on a plpgsql `record` raises `record "v_c" is not assigned yet` — the
|
|
41
|
+
-- exception HANDLER was aborting the transaction. Use a scalar (`v_id uuid`).
|
|
42
|
+
-- * Writing `pcap_state` while still under `set local role service_role` raises
|
|
43
|
+
-- `permission denied for table pcap_state`: the temp table belongs to the session
|
|
44
|
+
-- user. Every temp-table write below therefore happens after `reset role`.
|
|
45
|
+
--
|
|
46
|
+
-- If you add a probe here that can raise, trap it. An untrapped one does not fail loudly;
|
|
47
|
+
-- it silently stops the file.
|
|
4
48
|
create extension if not exists pgtap with schema extensions;
|
|
5
49
|
set search_path = extensions, public, agent_board;
|
|
6
50
|
|
|
7
51
|
begin;
|
|
8
52
|
|
|
9
|
-
select plan(
|
|
53
|
+
select plan(20);
|
|
10
54
|
|
|
11
55
|
select has_function(
|
|
12
56
|
'agent_board', 'issue_signing_key_challenge', array['uuid', 'text', 'interval'],
|
|
13
57
|
'issue_signing_key_challenge exists');
|
|
58
|
+
select has_function(
|
|
59
|
+
'agent_board', 'mint_fleet_agent_session', array['uuid', 'text', 'uuid', 'uuid', 'integer'],
|
|
60
|
+
'mint_fleet_agent_session exists');
|
|
14
61
|
|
|
15
|
-
--
|
|
62
|
+
-- Seed one agent + key, then fill the key's sixteen pending slots.
|
|
16
63
|
do $$
|
|
17
64
|
declare
|
|
18
65
|
v_agent uuid := '00000000-0000-0000-0000-0000000000c1';
|
|
19
66
|
v_i int;
|
|
20
67
|
v_c record;
|
|
68
|
+
v_ids uuid[] := '{}';
|
|
21
69
|
begin
|
|
22
70
|
insert into agent_board.agents(id, agent_type, handle, display_name)
|
|
23
71
|
values (v_agent, 'agent', 'pcap-agent', 'PCAP Agent');
|
|
@@ -27,60 +75,367 @@ begin
|
|
|
27
75
|
v_agent, 'primary', 1, public.test_pubkey('pcap-primary'), 'raw_ed25519_base64url',
|
|
28
76
|
public.test_key_fingerprint('pcap-primary'));
|
|
29
77
|
|
|
78
|
+
-- Issue as service_role, then RESET before back-dating: service_role holds SELECT
|
|
79
|
+
-- only on agent_signing_key_challenges, so the UPDATE must run as the owner.
|
|
80
|
+
-- created_at is set explicitly rather than relying on clock order, because every
|
|
81
|
+
-- row in one statement-less loop can share a now().
|
|
30
82
|
set local role service_role;
|
|
31
|
-
|
|
32
83
|
for v_i in 1..16 loop
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
84
|
+
-- Trapped like every other call site here. These sixteen are below the cap and do not
|
|
85
|
+
-- raise today; if a change makes one raise, this leaves v_ids short and assertion 3
|
|
86
|
+
-- fails BY NAME instead of the raise aborting the transaction and emitting nothing.
|
|
87
|
+
begin
|
|
88
|
+
select * into v_c
|
|
89
|
+
from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
|
|
90
|
+
v_ids := array_append(v_ids, v_c.challenge_id);
|
|
91
|
+
exception when others then
|
|
92
|
+
exit;
|
|
93
|
+
end;
|
|
38
94
|
end loop;
|
|
95
|
+
reset role;
|
|
96
|
+
|
|
97
|
+
for v_i in 1..coalesce(array_length(v_ids, 1), 0) loop
|
|
98
|
+
update agent_board.agent_signing_key_challenges
|
|
99
|
+
set created_at = now() - make_interval(secs => (17 - v_i))
|
|
100
|
+
where id = v_ids[v_i];
|
|
101
|
+
end loop;
|
|
102
|
+
end
|
|
103
|
+
$$;
|
|
104
|
+
reset role;
|
|
105
|
+
|
|
106
|
+
-- 1. The bound is reached, and every one of the sixteen is genuinely pending.
|
|
107
|
+
select is(
|
|
108
|
+
(select count(*)::int from agent_board.agent_signing_key_challenges
|
|
109
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
110
|
+
and proof_state = 'pending' and consumed_at is null and expires_at > now()),
|
|
111
|
+
16,
|
|
112
|
+
'sixteen challenges issue below the cap and are all pending');
|
|
113
|
+
|
|
114
|
+
-- Remember which row is the oldest before the 17th arrives.
|
|
115
|
+
create temporary table pcap_state as
|
|
116
|
+
select (select id from agent_board.agent_signing_key_challenges
|
|
117
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
118
|
+
and proof_state = 'pending' and consumed_at is null
|
|
119
|
+
order by created_at, id limit 1) as oldest_id,
|
|
120
|
+
null::uuid as seventeenth_id,
|
|
121
|
+
null::uuid as next_oldest_id,
|
|
122
|
+
null::uuid as victim_id,
|
|
123
|
+
null::text as lapse_note;
|
|
124
|
+
|
|
125
|
+
-- 2. The 17th request is SERVED, not refused. This is the assertion that inverts.
|
|
126
|
+
do $$
|
|
127
|
+
declare
|
|
128
|
+
v_agent uuid := '00000000-0000-0000-0000-0000000000c1';
|
|
129
|
+
v_id uuid;
|
|
130
|
+
begin
|
|
131
|
+
set local role service_role;
|
|
132
|
+
begin
|
|
133
|
+
select c.challenge_id into v_id
|
|
134
|
+
from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes') c;
|
|
135
|
+
exception when others then
|
|
136
|
+
-- Trapped, never re-raised: a bare call to a raising RPC aborts the whole
|
|
137
|
+
-- transaction, and pgTAP then emits NO `not ok` -- only a short assertion count
|
|
138
|
+
-- (AGENTS.md rule 3). Leaving the id null makes the assertion below fail BY NAME.
|
|
139
|
+
v_id := null;
|
|
140
|
+
end;
|
|
141
|
+
reset role;
|
|
142
|
+
update pcap_state set seventeenth_id = v_id;
|
|
143
|
+
end
|
|
144
|
+
$$;
|
|
145
|
+
reset role;
|
|
146
|
+
|
|
147
|
+
select isnt(
|
|
148
|
+
(select seventeenth_id from pcap_state), null,
|
|
149
|
+
'the 17th request at the cap is served, not refused (challenge_pending_cap is not raised)');
|
|
150
|
+
|
|
151
|
+
-- 3. The resource bound the cap exists for is still held.
|
|
152
|
+
select is(
|
|
153
|
+
(select count(*)::int from agent_board.agent_signing_key_challenges
|
|
154
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
155
|
+
and proof_state = 'pending' and consumed_at is null and expires_at > now()),
|
|
156
|
+
16,
|
|
157
|
+
'the per-key pending bound is still 16 after the 17th issues');
|
|
158
|
+
|
|
159
|
+
-- 4-7. The evicted row is the OLDEST one, closed out in the shape the
|
|
160
|
+
-- consumed-shape CHECK requires, and it names what superseded it.
|
|
161
|
+
select is(
|
|
162
|
+
(select proof_state from agent_board.agent_signing_key_challenges
|
|
163
|
+
where id = (select oldest_id from pcap_state)),
|
|
164
|
+
'expired',
|
|
165
|
+
'the oldest pending challenge is closed out as expired');
|
|
166
|
+
select ok(
|
|
167
|
+
(select consumed_at is not null from agent_board.agent_signing_key_challenges
|
|
168
|
+
where id = (select oldest_id from pcap_state)),
|
|
169
|
+
'the superseded row carries consumed_at (agent_signing_key_challenges_consumed_shape)');
|
|
170
|
+
-- `ok(a = b)`, NOT `is(a, b)`. pgTAP's `is` is IS NOT DISTINCT FROM, so under the
|
|
171
|
+
-- revert-supersede mutation — where nothing is evicted and nothing is issued, leaving
|
|
172
|
+
-- BOTH sides null — `is(null, null)` PASSES. This assertion was cited in the PR as one of
|
|
173
|
+
-- the nine that mutation fails, and it was in fact vacuous there: a probe that scores
|
|
174
|
+
-- 0-vs-0 and reads as agreement. `=` yields NULL when either side is null, and pgTAP
|
|
175
|
+
-- fails a NULL result, so the null case now reads `not ok`.
|
|
176
|
+
select ok(
|
|
177
|
+
(select (metadata->>'superseded_by')::uuid from agent_board.agent_signing_key_challenges
|
|
178
|
+
where id = (select oldest_id from pcap_state))
|
|
179
|
+
= (select seventeenth_id from pcap_state),
|
|
180
|
+
'the superseded row names the challenge that superseded it');
|
|
181
|
+
select is(
|
|
182
|
+
(select count(*)::int from agent_board.agent_signing_key_challenges
|
|
183
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
184
|
+
and metadata ? 'superseded_by'),
|
|
185
|
+
1,
|
|
186
|
+
'exactly one row was evicted -- the oldest, and only the oldest');
|
|
39
187
|
|
|
188
|
+
-- 8. A second request at the cap takes the NEXT-oldest, not the same row twice and
|
|
189
|
+
-- not an arbitrary one.
|
|
190
|
+
do $$
|
|
191
|
+
declare
|
|
192
|
+
v_next uuid;
|
|
193
|
+
v_agent uuid := '00000000-0000-0000-0000-0000000000c1';
|
|
194
|
+
begin
|
|
195
|
+
select id into v_next from agent_board.agent_signing_key_challenges
|
|
196
|
+
where agent_id = v_agent and proof_state = 'pending' and consumed_at is null
|
|
197
|
+
order by created_at, id limit 1;
|
|
198
|
+
update pcap_state set next_oldest_id = v_next;
|
|
199
|
+
set local role service_role;
|
|
40
200
|
begin
|
|
41
|
-
perform *
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
exception
|
|
45
|
-
when others then
|
|
46
|
-
if sqlerrm !~ 'challenge_pending_cap' then
|
|
47
|
-
raise;
|
|
48
|
-
end if;
|
|
201
|
+
perform * from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
|
|
202
|
+
exception when others then
|
|
203
|
+
null;
|
|
49
204
|
end;
|
|
205
|
+
reset role;
|
|
50
206
|
end
|
|
51
207
|
$$;
|
|
52
208
|
reset role;
|
|
53
209
|
|
|
54
|
-
select
|
|
55
|
-
select
|
|
210
|
+
select is(
|
|
211
|
+
(select proof_state from agent_board.agent_signing_key_challenges
|
|
212
|
+
where id = (select next_oldest_id from pcap_state)),
|
|
213
|
+
'expired',
|
|
214
|
+
'a second request at the cap evicts the NEXT-oldest row, not the same one twice');
|
|
215
|
+
select is(
|
|
216
|
+
(select count(*)::int from agent_board.agent_signing_key_challenges
|
|
217
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
218
|
+
and metadata ? 'superseded_by'),
|
|
219
|
+
2,
|
|
220
|
+
'a second request at the cap evicts one row, not several');
|
|
221
|
+
|
|
222
|
+
-- 9-10. An eviction is reported as an eviction, NOT as a replay. A client may retry
|
|
223
|
+
-- challenge_superseded; it must never learn to retry nonce_replay, so the two must not
|
|
224
|
+
-- share a message.
|
|
225
|
+
select throws_matching(
|
|
226
|
+
$q$ select agent_board.mint_fleet_agent_session(
|
|
227
|
+
(select oldest_id from pcap_state),
|
|
228
|
+
(select challenge_nonce from agent_board.agent_signing_key_challenges
|
|
229
|
+
where id = (select oldest_id from pcap_state)),
|
|
230
|
+
(select id from agent_board.agent_signing_keys
|
|
231
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'),
|
|
232
|
+
'00000000-0000-0000-0000-0000000000c1'::uuid,
|
|
233
|
+
900) $q$,
|
|
234
|
+
'challenge_superseded',
|
|
235
|
+
'minting a superseded challenge raises challenge_superseded');
|
|
236
|
+
|
|
237
|
+
-- A genuinely CONSUMED challenge still raises nonce_replay: the new branch narrowed
|
|
238
|
+
-- nothing. Consume one of the still-pending rows by hand and mint it.
|
|
239
|
+
update agent_board.agent_signing_key_challenges
|
|
240
|
+
set proof_state = 'consumed', consumed_at = now()
|
|
241
|
+
where id = (select id from agent_board.agent_signing_key_challenges
|
|
242
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
243
|
+
and proof_state = 'pending' and consumed_at is null
|
|
244
|
+
order by created_at desc, id limit 1);
|
|
245
|
+
|
|
246
|
+
select throws_matching(
|
|
247
|
+
$q$ select agent_board.mint_fleet_agent_session(
|
|
248
|
+
(select id from agent_board.agent_signing_key_challenges
|
|
249
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
250
|
+
and proof_state = 'consumed' and not (metadata ? 'superseded_by')
|
|
251
|
+
order by consumed_at desc limit 1),
|
|
252
|
+
(select challenge_nonce from agent_board.agent_signing_key_challenges
|
|
253
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
254
|
+
and proof_state = 'consumed' and not (metadata ? 'superseded_by')
|
|
255
|
+
order by consumed_at desc limit 1),
|
|
256
|
+
(select id from agent_board.agent_signing_keys
|
|
257
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'),
|
|
258
|
+
'00000000-0000-0000-0000-0000000000c1'::uuid,
|
|
259
|
+
900) $q$,
|
|
260
|
+
'nonce_replay',
|
|
261
|
+
'a genuinely consumed challenge still raises nonce_replay, not challenge_superseded');
|
|
56
262
|
|
|
57
|
-
--
|
|
58
|
-
--
|
|
263
|
+
-- 11-12. An EXPIRED-by-lapse pending row frees a slot without being evicted: expiry
|
|
264
|
+
-- and eviction remain different things, and expiry still costs no one their challenge.
|
|
59
265
|
update agent_board.agent_signing_key_challenges
|
|
60
266
|
set expires_at = now() - interval '1 minute'
|
|
61
|
-
where id = (
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
order by created_at
|
|
66
|
-
limit 1);
|
|
267
|
+
where id = (select id from agent_board.agent_signing_key_challenges
|
|
268
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
269
|
+
and proof_state = 'pending' and consumed_at is null
|
|
270
|
+
order by created_at desc, id limit 1);
|
|
67
271
|
|
|
68
272
|
do $$
|
|
69
273
|
declare
|
|
70
274
|
v_agent uuid := '00000000-0000-0000-0000-0000000000c1';
|
|
71
|
-
|
|
275
|
+
v_note text;
|
|
276
|
+
begin
|
|
277
|
+
set local role service_role;
|
|
278
|
+
begin
|
|
279
|
+
perform * from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
|
|
280
|
+
v_note := 'issued';
|
|
281
|
+
exception when others then
|
|
282
|
+
v_note := sqlerrm;
|
|
283
|
+
end;
|
|
284
|
+
-- The pcap_state write happens as the OWNER: service_role has no privilege on a
|
|
285
|
+
-- temp table created by the session user, and a permission error here would abort
|
|
286
|
+
-- the transaction exactly as an untrapped raise would.
|
|
287
|
+
reset role;
|
|
288
|
+
update pcap_state set lapse_note = v_note;
|
|
289
|
+
end
|
|
290
|
+
$$;
|
|
291
|
+
reset role;
|
|
292
|
+
|
|
293
|
+
select is(
|
|
294
|
+
(select lapse_note from pcap_state), 'issued',
|
|
295
|
+
'a lapsed pending row frees a slot, so the next request issues rather than raising');
|
|
296
|
+
|
|
297
|
+
select is(
|
|
298
|
+
(select count(*)::int from agent_board.agent_signing_key_challenges
|
|
299
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
300
|
+
and metadata ? 'superseded_by'),
|
|
301
|
+
2,
|
|
302
|
+
'a lapsed pending row frees a slot with NO eviction (still 2 superseded)');
|
|
303
|
+
select is(
|
|
304
|
+
(select count(*)::int from agent_board.agent_signing_key_challenges
|
|
305
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c1'
|
|
306
|
+
and proof_state = 'pending' and consumed_at is null and expires_at > now()),
|
|
307
|
+
15,
|
|
308
|
+
'the pending set never exceeds the bound across evictions and lapses');
|
|
309
|
+
|
|
310
|
+
-- 13. The grant surface is unchanged by the create-or-replace.
|
|
311
|
+
select is(
|
|
312
|
+
(select array_agg(g order by g)
|
|
313
|
+
from unnest(array['anon', 'authenticated', 'service_role']) g
|
|
314
|
+
where has_function_privilege(
|
|
315
|
+
g, 'agent_board.issue_signing_key_challenge(uuid,text,interval)', 'execute')),
|
|
316
|
+
array['authenticated', 'service_role'],
|
|
317
|
+
'issue_signing_key_challenge stays authenticated + service_role, never anon');
|
|
318
|
+
|
|
319
|
+
-- 14. THE DEADLOCK-FREEDOM CLAUSE, pinned positively.
|
|
320
|
+
--
|
|
321
|
+
-- The eviction writes agent_signing_key_challenges while this function holds the
|
|
322
|
+
-- agent_signing_keys row; mint_fleet_agent_session takes that pair in the opposite
|
|
323
|
+
-- order (challenge row FOR UPDATE at 20260718000600:73-76, key row FOR UPDATE at
|
|
324
|
+
-- :95-100). `for update skip locked` is what keeps that from being a live ABBA:
|
|
325
|
+
-- an issuer that never waits on a challenge row cannot close the cycle.
|
|
326
|
+
--
|
|
327
|
+
-- agent_board_fk_lock_edges.test.sql FKL-10 reports the PAIR (its detector is a
|
|
328
|
+
-- static scan and cannot see a SKIP LOCKED, so the pair is listed there as known),
|
|
329
|
+
-- which means removing this clause would fail NOTHING there. This is the named
|
|
330
|
+
-- assertion that removing it fails. Read over stripped_body(), which removes
|
|
331
|
+
-- comments and literals in one pass, so the prose above cannot satisfy the match --
|
|
332
|
+
-- a comment satisfying a body scan is how a prior class assertion in this repo went
|
|
333
|
+
-- green over an inverted lock.
|
|
334
|
+
--
|
|
335
|
+
-- A source scan has a floor, and this one is bounded rather than trusted: the
|
|
336
|
+
-- BEHAVIOUR (an issuer skipping a mint-held row instead of waiting) is proved across
|
|
337
|
+
-- two real sessions by tests/isolation/specs/challenge_supersede_lock_order.spec.
|
|
338
|
+
-- Neither check covers the other -- a scan cannot observe a wait, and the golden
|
|
339
|
+
-- cannot tell you which function the clause is missing from.
|
|
340
|
+
select matches(
|
|
341
|
+
(select agent_board_test.stripped_body(
|
|
342
|
+
'agent_board.issue_signing_key_challenge(uuid,text,interval)'::regprocedure)),
|
|
343
|
+
'for\s+update\s+skip\s+locked',
|
|
344
|
+
'the eviction selects its victim FOR UPDATE SKIP LOCKED (no wait => no ABBA with mint_fleet_agent_session)');
|
|
345
|
+
|
|
346
|
+
-- 19-20. THE RESIDUAL, PINNED RATHER THAN LEFT IN PROSE.
|
|
347
|
+
--
|
|
348
|
+
-- What this change closes is the HOLDABLE lockout: filling a key's sixteen slots and
|
|
349
|
+
-- walking away no longer denies anything, because arrival is never refused. What it does
|
|
350
|
+
-- NOT close is eviction under a SUSTAINED flood. A challenge issued now is the NEWEST, so
|
|
351
|
+
-- it survives fifteen further issues and is evicted by the sixteenth -- and there is no
|
|
352
|
+
-- per-key aggregate bound on how fast those sixteen arrive, because a per-key aggregate
|
|
353
|
+
-- bound is exactly the holdable lever this change removed. Per source it is bounded by the
|
|
354
|
+
-- `session:` rate cap; across N sources the sixteen land in 1/N of that time.
|
|
355
|
+
--
|
|
356
|
+
-- So a minter whose issue->mint gap exceeds the flood's rotation period loses its
|
|
357
|
+
-- challenge, for as long as the flood runs. That is DoS-class -- it costs the attacker
|
|
358
|
+
-- continuous traffic and stores nothing -- and it is a real residual, recorded here as an
|
|
359
|
+
-- assertion instead of a sentence because a sentence is not re-checked (rule 5).
|
|
360
|
+
--
|
|
361
|
+
-- NOT A DUPLICATE OF #12. That one mints a row which was ALREADY the oldest of sixteen and
|
|
362
|
+
-- was evicted by ONE further issue. This one starts from a challenge that is the NEWEST of
|
|
363
|
+
-- its key and shows the whole queue rotating past it -- the property under discussion is
|
|
364
|
+
-- that a LIVE minter can be rotated out, which #12's setup cannot exhibit.
|
|
365
|
+
--
|
|
366
|
+
-- The count is load-bearing and was measured, not assumed: with fifteen further issues
|
|
367
|
+
-- both assertions below FAIL (the victim survives). Sixteen is the number.
|
|
368
|
+
do $$
|
|
369
|
+
declare
|
|
370
|
+
v_agent uuid := '00000000-0000-0000-0000-0000000000c2';
|
|
371
|
+
v_victim uuid;
|
|
372
|
+
v_i int;
|
|
72
373
|
begin
|
|
374
|
+
insert into agent_board.agents(id, agent_type, handle, display_name)
|
|
375
|
+
values (v_agent, 'agent', 'pcap-victim', 'PCAP Victim');
|
|
376
|
+
-- active + proven deliberately: mint checks the key AFTER the superseded branch, so an
|
|
377
|
+
-- inactive key would let assertion 20 pass on `key_revoked` instead of on the property.
|
|
378
|
+
insert into agent_board.agent_signing_keys(
|
|
379
|
+
agent_id, key_id, key_version, public_key, public_key_format, fingerprint, status, proof_state)
|
|
380
|
+
values (
|
|
381
|
+
v_agent, 'primary', 1, public.test_pubkey('pcap-victim'), 'raw_ed25519_base64url',
|
|
382
|
+
public.test_key_fingerprint('pcap-victim'), 'active', 'proven');
|
|
383
|
+
|
|
73
384
|
set local role service_role;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
385
|
+
-- The victim obtains a challenge, exactly as a legitimate agent would.
|
|
386
|
+
begin
|
|
387
|
+
select c.challenge_id into v_victim
|
|
388
|
+
from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes') c;
|
|
389
|
+
exception when others then
|
|
390
|
+
v_victim := null; -- trapped: an untrapped raise kills the file (see the header)
|
|
391
|
+
end;
|
|
392
|
+
reset role;
|
|
393
|
+
|
|
394
|
+
-- Back-date the victim so "oldest" is unambiguous. `now()` is TRANSACTION start time, so
|
|
395
|
+
-- every row this pgTAP transaction inserts shares one created_at and the eviction's
|
|
396
|
+
-- `order by created_at, id` degenerates to uuid order -- which made the first version of
|
|
397
|
+
-- this block evict a random row and fail for a reason that had nothing to do with the
|
|
398
|
+
-- property. In production each issue is its own transaction and now() advances, so this
|
|
399
|
+
-- back-date restores the real ordering rather than inventing one. Owner-only UPDATE,
|
|
400
|
+
-- hence the reset above.
|
|
401
|
+
update agent_board.agent_signing_key_challenges
|
|
402
|
+
set created_at = now() - interval '1 hour'
|
|
403
|
+
where id = v_victim;
|
|
404
|
+
|
|
405
|
+
set local role service_role;
|
|
406
|
+
-- Sixteen further issues for the SAME key. The 16th finds the set full and evicts the
|
|
407
|
+
-- oldest, which by then is the victim's.
|
|
408
|
+
for v_i in 1..16 loop
|
|
409
|
+
begin
|
|
410
|
+
perform * from agent_board.issue_signing_key_challenge(v_agent, 'primary', interval '5 minutes');
|
|
411
|
+
exception when others then
|
|
412
|
+
null;
|
|
413
|
+
end;
|
|
414
|
+
end loop;
|
|
415
|
+
reset role;
|
|
416
|
+
|
|
417
|
+
update pcap_state set victim_id = v_victim;
|
|
79
418
|
end
|
|
80
419
|
$$;
|
|
81
420
|
reset role;
|
|
82
421
|
|
|
83
|
-
select
|
|
422
|
+
select is(
|
|
423
|
+
(select proof_state from agent_board.agent_signing_key_challenges
|
|
424
|
+
where id = (select victim_id from pcap_state)),
|
|
425
|
+
'expired',
|
|
426
|
+
'RESIDUAL: a live challenge is rotated out by sixteen later issues for the same key');
|
|
427
|
+
|
|
428
|
+
select throws_matching(
|
|
429
|
+
$q$ select agent_board.mint_fleet_agent_session(
|
|
430
|
+
(select victim_id from pcap_state),
|
|
431
|
+
(select challenge_nonce from agent_board.agent_signing_key_challenges
|
|
432
|
+
where id = (select victim_id from pcap_state)),
|
|
433
|
+
(select id from agent_board.agent_signing_keys
|
|
434
|
+
where agent_id = '00000000-0000-0000-0000-0000000000c2'),
|
|
435
|
+
'00000000-0000-0000-0000-0000000000c2'::uuid,
|
|
436
|
+
900) $q$,
|
|
437
|
+
'challenge_superseded',
|
|
438
|
+
'RESIDUAL: that victim''s mint then fails -- a sustained flood evicts a slow minter (DoS-class, retryable, not a stored lockout)');
|
|
84
439
|
|
|
85
440
|
select finish();
|
|
86
441
|
|