vta_cli_common/consent.rs
1//! Wait out a `requireConsent` gate from a CLI.
2//!
3//! A consent-gated task is not refused, it is *deferred*: the VTA raises a
4//! question, pushes it to the approver set, and holds the answer. Until this
5//! existed, no CLI could answer it — `pnm` printed `auth:consent_required` and
6//! exited, so every gated task was simply unreachable from the command line
7//! however privileged the operator was. The browser extension implemented the
8//! loop; nothing in Rust did.
9//!
10//! ## Re-submitting is the only way to ask
11//!
12//! There is no read-only status surface for task-consent, so "has it been
13//! approved yet?" can only be asked by submitting the task again. That is safe
14//! **while the request is pending** and unsafe once it is resolved, and the
15//! difference is load-bearing:
16//!
17//! - Pending: the gate recognises the same payload, returns the *same*
18//! `challenge`, and deliberately does not re-notify. The push follows the
19//! question, not the submit — so polling cannot ring the approver's device.
20//! - Denied or lapsed: the pending record is **deleted**. The next submit finds
21//! nothing, raises a *new* question, and pushes again.
22//!
23//! So the loop stops the moment the challenge changes. Continuing would turn a
24//! "no" into a nag, which is the habituation attack the gate's own design notes
25//! warn about — a consent prompt an attacker can summon on demand is worth more
26//! to them than one they must wait for. One re-prompt is unavoidable without a
27//! server-side status task; an unbounded stream of them is not.
28//!
29//! ## What the operator has to do
30//!
31//! Compare the code printed here against the code on the approving device, and
32//! approve only if they match. That comparison is the entire security value of
33//! the flow: the digest is what the approver signs, so two screens showing the
34//! same code means the thing being approved is the thing that will run.
35
36use std::time::Duration;
37
38use vta_sdk::error::VtaError;
39
40/// How often to re-ask while the request is pending.
41///
42/// Each tick is a submit the gate answers from its pending record without
43/// notifying anyone, so this trades responsiveness against request volume
44/// only. Three seconds keeps a human-paced approval feeling immediate.
45const POLL_INTERVAL: Duration = Duration::from_secs(3);
46
47/// How long to wait before giving up.
48///
49/// Bounded because the operator is sitting at a terminal. Giving up is not
50/// failure — the request stays pending server-side, so re-running the same
51/// command resumes waiting on the same challenge.
52const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
53
54/// Why the wait ended without the task running.
55#[derive(Debug)]
56pub enum ConsentOutcome {
57 /// The approver said no, or the request lapsed — either way the pending
58 /// record is gone and the challenge we were waiting on will never be
59 /// answered.
60 Resolved,
61 /// Nobody answered within the timeout. The request is still pending.
62 TimedOut,
63}
64
65/// Print the approval prompt for a freshly raised consent request.
66fn announce(payload_digest: &str, approver_set: &str, min_approvals: u32, exclude_requester: bool) {
67 eprintln!();
68 eprintln!(" Approval required before this can run.");
69 eprintln!();
70 eprintln!(" code: {payload_digest}");
71 eprintln!();
72 if exclude_requester {
73 eprintln!(
74 " {min_approvals} approval(s) needed from `{approver_set}`, and this device is not \
75 eligible to give them — your policy requires a different device."
76 );
77 eprintln!(" Check the code above matches the one on your approving device, then approve");
78 eprintln!(" it there. Do not approve a code that differs: a mismatch means the change");
79 eprintln!(" being shown is not the change that would be made.");
80 } else {
81 eprintln!(
82 " {min_approvals} approval(s) needed from `{approver_set}`. This device may give \
83 one if its DID is a member of that set."
84 );
85 eprintln!(" Approve on whichever enrolled device is showing this code.");
86 }
87 eprintln!();
88 eprintln!(" Waiting… (Ctrl-C to stop; the request stays pending and re-running resumes it)");
89}
90
91/// Run `submit`, and if the VTA defers it for consent, wait for the approval
92/// and run it again.
93///
94/// `submit` MUST produce a byte-identical request each time it is called. The
95/// grant is bound to a digest of the payload, so a request that differs on
96/// retry — a regenerated nonce, a re-read timestamp — will not match the
97/// approval and will raise a second, unanswerable question instead.
98///
99/// Returns `Ok(Ok(value))` when the task ran, `Ok(Err(outcome))` when the wait
100/// ended without it running, and `Err` for any non-consent failure.
101pub async fn with_consent<F, Fut, T>(submit: F) -> Result<Result<T, ConsentOutcome>, VtaError>
102where
103 F: Fn() -> Fut,
104 Fut: Future<Output = Result<T, VtaError>>,
105{
106 with_consent_timeout(submit, DEFAULT_TIMEOUT).await
107}
108
109/// [`with_consent`] with an explicit deadline. Separate so tests do not sleep
110/// for the production timeout.
111pub async fn with_consent_timeout<F, Fut, T>(
112 submit: F,
113 timeout: Duration,
114) -> Result<Result<T, ConsentOutcome>, VtaError>
115where
116 F: Fn() -> Fut,
117 Fut: Future<Output = Result<T, VtaError>>,
118{
119 // The challenge from the first refusal. Everything below is about noticing
120 // when the server stops answering with this one.
121 let waiting_on = match submit().await {
122 Ok(value) => return Ok(Ok(value)),
123 Err(VtaError::ConsentRequired {
124 payload_digest,
125 challenge,
126 approver_set,
127 min_approvals,
128 exclude_requester,
129 }) => {
130 announce(
131 &payload_digest,
132 &approver_set,
133 min_approvals,
134 exclude_requester,
135 );
136 challenge
137 }
138 Err(other) => return Err(other),
139 };
140
141 let deadline = tokio::time::Instant::now() + timeout;
142 loop {
143 if tokio::time::Instant::now() >= deadline {
144 return Ok(Err(ConsentOutcome::TimedOut));
145 }
146 tokio::time::sleep(POLL_INTERVAL).await;
147
148 match submit().await {
149 Ok(value) => return Ok(Ok(value)),
150 Err(VtaError::ConsentRequired { challenge, .. }) if challenge == waiting_on => {
151 // Still the same question. The gate answered from its pending
152 // record and notified nobody; keep waiting.
153 }
154 Err(VtaError::ConsentRequired { .. }) => {
155 // A *different* challenge means our request is gone — denied,
156 // or lapsed — and this submit has just raised a fresh one.
157 // Stop here: asking again is what turns a refusal into a nag.
158 return Ok(Err(ConsentOutcome::Resolved));
159 }
160 Err(other) => return Err(other),
161 }
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use std::sync::atomic::{AtomicUsize, Ordering};
169
170 fn consent_required(challenge: &str) -> VtaError {
171 VtaError::ConsentRequired {
172 payload_digest: "ABC123".into(),
173 challenge: challenge.into(),
174 approver_set: "webvh-approvers".into(),
175 min_approvals: 1,
176 exclude_requester: true,
177 }
178 }
179
180 /// The happy path: deferred, then approved.
181 #[tokio::test(start_paused = true)]
182 async fn an_approval_lets_the_task_through() {
183 let calls = AtomicUsize::new(0);
184 let out = with_consent(|| async {
185 // Deferred twice, then the approval lands.
186 match calls.fetch_add(1, Ordering::SeqCst) {
187 0 | 1 => Err(consent_required("chal-1")),
188 _ => Ok("published"),
189 }
190 })
191 .await
192 .expect("no transport error");
193
194 assert!(matches!(out, Ok("published")));
195 assert_eq!(calls.load(Ordering::SeqCst), 3, "polled until approved");
196 }
197
198 /// A task that is not gated must not pay for the machinery.
199 #[tokio::test(start_paused = true)]
200 async fn an_ungated_task_runs_immediately() {
201 let calls = AtomicUsize::new(0);
202 let out = with_consent(|| async {
203 calls.fetch_add(1, Ordering::SeqCst);
204 Ok::<_, VtaError>("published")
205 })
206 .await
207 .expect("no transport error");
208
209 assert!(matches!(out, Ok("published")));
210 assert_eq!(calls.load(Ordering::SeqCst), 1, "submitted exactly once");
211 }
212
213 /// The rule that keeps a denial from becoming a nag.
214 ///
215 /// A denial deletes the pending request, so the submit that discovers it
216 /// has already raised — and pushed — a new one. Stopping on the changed
217 /// challenge bounds that at a single prompt. Without this the loop would
218 /// re-ask every tick, which is a consent prompt on demand.
219 #[tokio::test(start_paused = true)]
220 async fn a_changed_challenge_stops_the_loop() {
221 let calls = AtomicUsize::new(0);
222 let out = with_consent(|| async {
223 match calls.fetch_add(1, Ordering::SeqCst) {
224 0 => Err::<(), _>(consent_required("chal-1")),
225 // Denied: the pending is gone and this submit raised a new one.
226 _ => Err(consent_required("chal-2")),
227 }
228 })
229 .await
230 .expect("no transport error");
231
232 assert!(matches!(out, Err(ConsentOutcome::Resolved)));
233 assert_eq!(
234 calls.load(Ordering::SeqCst),
235 2,
236 "stopped on the first changed challenge — no further prompts"
237 );
238 }
239
240 /// Giving up leaves the request pending, so this is a bounded wait rather
241 /// than a failure.
242 #[tokio::test(start_paused = true)]
243 async fn waiting_is_bounded() {
244 let calls = AtomicUsize::new(0);
245 let out = with_consent_timeout(
246 || async {
247 calls.fetch_add(1, Ordering::SeqCst);
248 Err::<(), _>(consent_required("chal-1"))
249 },
250 Duration::from_secs(10),
251 )
252 .await
253 .expect("no transport error");
254
255 assert!(matches!(out, Err(ConsentOutcome::TimedOut)));
256 }
257
258 /// A real failure must not be mistaken for "still waiting".
259 #[tokio::test(start_paused = true)]
260 async fn a_non_consent_error_surfaces_immediately() {
261 let calls = AtomicUsize::new(0);
262 let err = with_consent(|| async {
263 match calls.fetch_add(1, Ordering::SeqCst) {
264 0 => Err::<(), _>(consent_required("chal-1")),
265 _ => Err(VtaError::Protocol("the DID vanished".into())),
266 }
267 })
268 .await
269 .expect_err("a transport error must propagate");
270
271 assert!(matches!(err, VtaError::Protocol(_)));
272 }
273}