Declarative provisioning that converges a provider to a spec
0

Configure Feed

Select the types of activity you want to include in your feed.

caravan: authenticate the issuing counter, and say when a window refuses one

The drained watermark and the witness floor were given tags; the counter the
operator issues sequences from was left plain, in the same directory, against
the same adversary. Rewinding it does not forge a command -- it makes the next
intents carry sequences at or below what the drain has already spent, so every
one is refused as a replay and the outbox quietly stops accepting work. It
carries a tag now, and a counter that is present and unusable stops the
queueing rather than silently restarting the sequence at one.

Quarantining an intent was also silent. The file moving to refused/ was the
only evidence, so an operator whose command was refused over a signature
learned nothing without going to look. The move now logs what it refused, why,
and that the next window is not blocked by it.

+59 -13
+34 -13
lib/spool/caravan_spool.ml
··· 134 134 135 135 let consumed_file t = t.spool / "outbox.consumed" 136 136 let witness_floor_file t = t.spool / "state.witness.version" 137 + let issued_file t = t.spool / "outbox.seq" 137 138 138 139 (* A keyed spool writes both counters at zero when they are absent, so from then 139 140 on absence means deletion and can be refused. Without this the first window ··· 143 144 if Option.is_some t.watermark_key then begin 144 145 if not (exists (consumed_file t)) then write_counter t (consumed_file t) "0"; 145 146 if not (exists (witness_floor_file t)) then 146 - write_counter t (witness_floor_file t) "0" 147 + write_counter t (witness_floor_file t) "0"; 148 + if not (exists (issued_file t)) then write_counter t (issued_file t) "0" 147 149 end 148 150 149 151 let v ?signer ?verify_key ?device_key ?watermark_key ~fs ~spool () = ··· 569 571 destroyed -- so the next window sees a clean outbox and the operator can see 570 572 what arrived. Only a verification failure quarantines: an unreadable file may 571 573 be transient, and moving that would lose a command the operator signed. *) 572 - let quarantine t name = 574 + let quarantine t name ~why = 575 + Log.warn (fun m -> 576 + m 577 + "refused intent %s and moved it to %a: %s. The window carried nothing; \ 578 + the next one will not be blocked by this file." 579 + name Eio.Path.pp (refused_dir t) why); 573 580 match Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 (refused_dir t) with 574 581 | exception Eio.Io _ -> () 575 582 | () -> ··· 603 610 match verify_intent t ~window ~drained queued with 604 611 | Ok () -> Ok () 605 612 | Error e -> 606 - quarantine t queued.name; 613 + quarantine t queued.name ~why:e; 607 614 Error e) 608 615 (Ok ()) window 609 616 in ··· 718 725 as the key does. Monotonic, never reused. *) 719 726 let issued_file t = t.spool / "outbox.seq" 720 727 728 + (* Authenticated like the other two counters, and for the same adversary. Rewinding 729 + this one does not replay anything -- it makes the operator issue sequences at or 730 + below the drained watermark, which the drain then refuses as replays -- so the 731 + attack is denial of service against the operator rather than a forged command. 732 + Same file, same fix. A counter that is present and unusable stops the queueing 733 + rather than silently restarting the sequence at one. *) 721 734 let issue t = 722 - let next = 723 - match read_lines (issued_file t) with 724 - | [ line ] -> 725 - Int64.add 726 - (Option.value (Int64.of_string_opt (String.trim line)) ~default:0L) 727 - 1L 728 - | _ -> 1L 735 + let* current = 736 + match read_counter t (issued_file t) with 737 + | None -> Ok 0L 738 + | Some (Ok value) -> ( 739 + match Int64.of_string_opt value with 740 + | Some n -> Ok n 741 + | None -> 742 + err "the issued-sequence counter %a does not hold a sequence" 743 + Eio.Path.pp (issued_file t)) 744 + | Some (Error _) -> 745 + err 746 + "the issued-sequence counter %a is not authentic: refusing to issue \ 747 + against a counter this spool cannot vouch for" 748 + Eio.Path.pp (issued_file t) 729 749 in 730 - write_atomically (issued_file t) (Fmt.str "%Ld\n" next); 731 - next 750 + let next = Int64.add current 1L in 751 + write_counter t (issued_file t) (Fmt.str "%Ld" next); 752 + Ok next 732 753 733 754 let enqueue t ~verb name lines = 734 - let seq = issue t in 755 + let* seq = issue t in 735 756 write_outbox t name 736 757 (String.concat "\n" 737 758 (Fmt.str "intent: %s" verb :: Fmt.str "seq: %Ld" seq :: lines)
+25
test/spool/test_caravan_spool.ml
··· 799 799 "the next window is not wedged" [ stage_intent ] carried 800 800 | Error e -> Alcotest.failf "a clean outbox must drain: %s" e 801 801 802 + (* The issuing counter is the third of the spool's counters. Rewinding it does not 803 + replay a command -- it makes the operator issue sequences the drain then refuses 804 + as spent, so the outbox silently stops accepting work. It carries a tag like the 805 + other two, and an unauthenticated one stops the queueing rather than restarting 806 + the sequence at one. *) 807 + let a_forged_issuing_counter_stops_queueing () = 808 + with_signed_spool ~signer:intent_signer ~verify_key:intent_pub ~watermark_key 809 + @@ fun root backend -> 810 + stage_exn backend; 811 + let counter = Filename.concat root "outbox.seq" in 812 + Out_channel.with_open_bin counter (fun oc -> 813 + Out_channel.output_string oc "0\n"); 814 + match 815 + Caravan_spool.stage_release backend ~instance:"sat-1" ~image:"release-3" 816 + ~tag:"caravan:provision:cfg-3" 817 + with 818 + | Ok () -> 819 + Alcotest.fail "queueing against an unauthenticated counter must refuse" 820 + | Error message -> 821 + Alcotest.(check bool) 822 + "the refusal names the counter" true 823 + (Astring.String.is_infix ~affix:"authentic" message) 824 + 802 825 let substrate_is_fixed () = 803 826 with_spool @@ fun backend -> 804 827 match Caravan_spool.add_volume backend ~name:"x" ~tag:"t" ~gib:1 ~iops:0 with ··· 830 853 a_replayed_intent_is_refused; 831 854 Alcotest.test_case "a dropped file does not wedge later windows" `Quick 832 855 a_dropped_file_does_not_wedge_later_windows; 856 + Alcotest.test_case "a forged issuing counter stops queueing" `Quick 857 + a_forged_issuing_counter_stops_queueing; 833 858 Alcotest.test_case "a reset watermark does not re-admit a spent intent" 834 859 `Quick a_reset_watermark_does_not_re_admit_a_spent_intent; 835 860 Alcotest.test_case "a substituted bootstrap document is refused" `Quick