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    // The code the approving device shows: derived from the decoded digest,
68    // exactly as every approver surface derives it. Printing the digest itself
69    // left the operator comparing a `zQm…` string against six hex characters.
70    let code = vta_sdk::task_consent::match_code(payload_digest)
71        .unwrap_or_else(|_| payload_digest.to_string());
72    eprintln!();
73    eprintln!("  Approval required before this can run.");
74    eprintln!();
75    eprintln!("      code: {code}");
76    eprintln!();
77    if exclude_requester {
78        eprintln!(
79            "  {min_approvals} approval(s) needed from `{approver_set}`, and this device is not \
80             eligible to give them — your policy requires a different device."
81        );
82        eprintln!("  Check the code above matches the one on your approving device, then approve");
83        eprintln!("  it there. Do not approve a code that differs: a mismatch means the change");
84        eprintln!("  being shown is not the change that would be made.");
85    } else {
86        eprintln!(
87            "  {min_approvals} approval(s) needed from `{approver_set}`. This device may give \
88             one if its DID is a member of that set."
89        );
90        eprintln!("  Approve on whichever enrolled device is showing this code.");
91    }
92    eprintln!();
93    eprintln!("  Waiting… (Ctrl-C to stop; the request stays pending and re-running resumes it)");
94}
95
96/// Run `submit`, and if the VTA defers it for consent, wait for the approval
97/// and run it again.
98///
99/// `submit` MUST produce a byte-identical request each time it is called. The
100/// grant is bound to a digest of the payload, so a request that differs on
101/// retry — a regenerated nonce, a re-read timestamp — will not match the
102/// approval and will raise a second, unanswerable question instead.
103///
104/// Returns `Ok(Ok(value))` when the task ran, `Ok(Err(outcome))` when the wait
105/// ended without it running, and `Err` for any non-consent failure.
106pub async fn with_consent<F, Fut, T>(submit: F) -> Result<Result<T, ConsentOutcome>, VtaError>
107where
108    F: Fn() -> Fut,
109    Fut: Future<Output = Result<T, VtaError>>,
110{
111    with_consent_timeout(submit, DEFAULT_TIMEOUT).await
112}
113
114/// [`with_consent`] with an explicit deadline. Separate so tests do not sleep
115/// for the production timeout.
116pub async fn with_consent_timeout<F, Fut, T>(
117    submit: F,
118    timeout: Duration,
119) -> Result<Result<T, ConsentOutcome>, VtaError>
120where
121    F: Fn() -> Fut,
122    Fut: Future<Output = Result<T, VtaError>>,
123{
124    // The challenge from the first refusal. Everything below is about noticing
125    // when the server stops answering with this one.
126    let waiting_on = match submit().await {
127        Ok(value) => return Ok(Ok(value)),
128        Err(VtaError::ConsentRequired {
129            payload_digest,
130            challenge,
131            approver_set,
132            min_approvals,
133            exclude_requester,
134        }) => {
135            announce(
136                &payload_digest,
137                &approver_set,
138                min_approvals,
139                exclude_requester,
140            );
141            challenge
142        }
143        Err(other) => return Err(other),
144    };
145
146    let deadline = tokio::time::Instant::now() + timeout;
147    loop {
148        if tokio::time::Instant::now() >= deadline {
149            return Ok(Err(ConsentOutcome::TimedOut));
150        }
151        tokio::time::sleep(POLL_INTERVAL).await;
152
153        match submit().await {
154            Ok(value) => return Ok(Ok(value)),
155            Err(VtaError::ConsentRequired { challenge, .. }) if challenge == waiting_on => {
156                // Still the same question. The gate answered from its pending
157                // record and notified nobody; keep waiting.
158            }
159            Err(VtaError::ConsentRequired { .. }) => {
160                // A *different* challenge means our request is gone — denied,
161                // or lapsed — and this submit has just raised a fresh one.
162                // Stop here: asking again is what turns a refusal into a nag.
163                return Ok(Err(ConsentOutcome::Resolved));
164            }
165            Err(other) => return Err(other),
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173    use std::sync::atomic::{AtomicUsize, Ordering};
174
175    fn consent_required(challenge: &str) -> VtaError {
176        VtaError::ConsentRequired {
177            payload_digest: "ABC123".into(),
178            challenge: challenge.into(),
179            approver_set: "webvh-approvers".into(),
180            min_approvals: 1,
181            exclude_requester: true,
182        }
183    }
184
185    /// The happy path: deferred, then approved.
186    #[tokio::test(start_paused = true)]
187    async fn an_approval_lets_the_task_through() {
188        let calls = AtomicUsize::new(0);
189        let out = with_consent(|| async {
190            // Deferred twice, then the approval lands.
191            match calls.fetch_add(1, Ordering::SeqCst) {
192                0 | 1 => Err(consent_required("chal-1")),
193                _ => Ok("published"),
194            }
195        })
196        .await
197        .expect("no transport error");
198
199        assert!(matches!(out, Ok("published")));
200        assert_eq!(calls.load(Ordering::SeqCst), 3, "polled until approved");
201    }
202
203    /// A task that is not gated must not pay for the machinery.
204    #[tokio::test(start_paused = true)]
205    async fn an_ungated_task_runs_immediately() {
206        let calls = AtomicUsize::new(0);
207        let out = with_consent(|| async {
208            calls.fetch_add(1, Ordering::SeqCst);
209            Ok::<_, VtaError>("published")
210        })
211        .await
212        .expect("no transport error");
213
214        assert!(matches!(out, Ok("published")));
215        assert_eq!(calls.load(Ordering::SeqCst), 1, "submitted exactly once");
216    }
217
218    /// The rule that keeps a denial from becoming a nag.
219    ///
220    /// A denial deletes the pending request, so the submit that discovers it
221    /// has already raised — and pushed — a new one. Stopping on the changed
222    /// challenge bounds that at a single prompt. Without this the loop would
223    /// re-ask every tick, which is a consent prompt on demand.
224    #[tokio::test(start_paused = true)]
225    async fn a_changed_challenge_stops_the_loop() {
226        let calls = AtomicUsize::new(0);
227        let out = with_consent(|| async {
228            match calls.fetch_add(1, Ordering::SeqCst) {
229                0 => Err::<(), _>(consent_required("chal-1")),
230                // Denied: the pending is gone and this submit raised a new one.
231                _ => Err(consent_required("chal-2")),
232            }
233        })
234        .await
235        .expect("no transport error");
236
237        assert!(matches!(out, Err(ConsentOutcome::Resolved)));
238        assert_eq!(
239            calls.load(Ordering::SeqCst),
240            2,
241            "stopped on the first changed challenge — no further prompts"
242        );
243    }
244
245    /// Giving up leaves the request pending, so this is a bounded wait rather
246    /// than a failure.
247    #[tokio::test(start_paused = true)]
248    async fn waiting_is_bounded() {
249        let calls = AtomicUsize::new(0);
250        let out = with_consent_timeout(
251            || async {
252                calls.fetch_add(1, Ordering::SeqCst);
253                Err::<(), _>(consent_required("chal-1"))
254            },
255            Duration::from_secs(10),
256        )
257        .await
258        .expect("no transport error");
259
260        assert!(matches!(out, Err(ConsentOutcome::TimedOut)));
261    }
262
263    /// A real failure must not be mistaken for "still waiting".
264    #[tokio::test(start_paused = true)]
265    async fn a_non_consent_error_surfaces_immediately() {
266        let calls = AtomicUsize::new(0);
267        let err = with_consent(|| async {
268            match calls.fetch_add(1, Ordering::SeqCst) {
269                0 => Err::<(), _>(consent_required("chal-1")),
270                _ => Err(VtaError::Protocol("the DID vanished".into())),
271            }
272        })
273        .await
274        .expect_err("a transport error must propagate");
275
276        assert!(matches!(err, VtaError::Protocol(_)));
277    }
278}