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}