Skip to main content

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}