cli/client/human_signature.rs
1// SPDX-License-Identifier: Apache-2.0
2//! CLI default human-signature (WebAuthn) callback for destructive hosted RPCs.
3//!
4//! When the server marks an RPC `human`-tier and rejects it with
5//! `x-weft-sig-required: human`, `heddle-client`'s request-signing interceptor
6//! invokes an app-registered callback to produce a WebAuthn assertion over the
7//! action, then retries once (see `heddle_client::HumanSignatureCallback`).
8//!
9//! # What the CLI supports vs defers
10//!
11//! A full WebAuthn ceremony needs a platform/roaming authenticator (touch,
12//! biometric, or security key) driven by an OS/browser WebAuthn stack. The
13//! `heddle` CLI runs headless in a terminal and has **no** in-process WebAuthn
14//! authenticator binding today, so it cannot mint a genuine assertion — and we
15//! must never fake one (a forged assertion would either be rejected by the
16//! server's UV check or, worse, defeat the entire human-gesture control).
17//!
18//! Therefore the CLI's default callback **surfaces a clear, typed
19//! user-verification-required error** naming a surface that can complete the
20//! ceremony (the web UI / tapestry), rather than attempting a partial/fake
21//! ceremony. The consent surface (the action summary) is still shown to the
22//! user before the error so they understand what was blocked.
23//!
24//! Deferred (tracked for a follow-up): binding a platform authenticator via a
25//! native WebAuthn crate (e.g. `webauthn-authenticator-rs`) so the CLI can
26//! prompt for a security-key touch inline. When that lands, this callback swaps
27//! its error branch for the real ceremony; the interceptor contract is
28//! unchanged.
29
30use std::sync::Arc;
31
32use heddle_client::{HumanSignatureCallback, HumanSignatureRequest, WebAuthnAssertion};
33use wire::ProtocolError;
34
35/// The default human-signature callback for CLI-opened hosted sessions.
36///
37/// Renders the action being authorized (so the user sees *what* required
38/// verification), then returns a typed error directing the user to a surface
39/// that can complete the WebAuthn ceremony. It never fabricates an assertion.
40pub fn cli_human_signature_callback() -> HumanSignatureCallback {
41 Arc::new(
42 |req: HumanSignatureRequest| -> Result<WebAuthnAssertion, ProtocolError> {
43 // Show the consent surface: the user should always learn which action
44 // was gated, even though the CLI can't complete the gesture itself.
45 eprintln!(
46 "⚠ This action requires user verification (WebAuthn), which the CLI can't perform in \
47 a headless terminal:\n {}",
48 req.action_summary
49 );
50 // When the server sent a deep-link (weft#338), point the user straight at the surface
51 // that CAN complete the ceremony; otherwise fall back to generic guidance. Either way
52 // we return a typed error and NEVER fabricate an assertion.
53 match req.action_url.as_deref() {
54 Some(url) => {
55 eprintln!("Complete it in the web app:\n {url}");
56 Err(ProtocolError::AuthorizationFailed(format!(
57 "user verification required for {}: complete this action in the web app:\n {}",
58 req.method_path, url
59 )))
60 }
61 None => {
62 eprintln!(
63 "The `heddle` CLI cannot perform the WebAuthn ceremony in a headless terminal."
64 );
65 Err(ProtocolError::AuthorizationFailed(format!(
66 "user verification required for {}: run this destructive action from a surface \
67 with a WebAuthn authenticator (the web UI), or re-run once CLI authenticator \
68 support lands",
69 req.method_path
70 )))
71 }
72 }
73 },
74 )
75}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 fn req_with_action_url(action_url: Option<String>) -> HumanSignatureRequest {
82 HumanSignatureRequest {
83 method_path: "/heddle.api.v1alpha1.RegistryService/DeleteRepository".to_string(),
84 action_summary: "Authorize /heddle.api.v1alpha1.RegistryService/DeleteRepository"
85 .to_string(),
86 challenge: "abc".to_string(),
87 canonical: b"heddle-req-sig-v1:...".to_vec(),
88 action_url,
89 }
90 }
91
92 /// Without a server deep-link, the callback keeps the generic guidance and still returns a
93 /// typed error, never an assertion.
94 #[test]
95 fn cli_callback_returns_typed_error_and_never_fakes_an_assertion() {
96 let cb = cli_human_signature_callback();
97 let result = cb(req_with_action_url(None));
98 match result {
99 Err(ProtocolError::AuthorizationFailed(msg)) => {
100 assert!(msg.contains("user verification required"));
101 assert!(msg.contains("DeleteRepository"));
102 // No URL was provided → generic guidance, no link.
103 assert!(msg.contains("web UI"));
104 assert!(!msg.contains("https://"));
105 }
106 other => panic!("expected a typed AuthorizationFailed error, got {other:?}"),
107 }
108 }
109
110 /// With a server deep-link (weft#338), the typed error message includes the URL so the user
111 /// can open it — and the callback still returns a typed error, never an assertion.
112 #[test]
113 fn cli_callback_includes_action_url_in_typed_error_when_present() {
114 let cb = cli_human_signature_callback();
115 let url = "https://app.heddle.sh/verify-action?method=%2Fheddle.api.v1alpha1.RegistryService%2FDeleteRepository&challenge=CHAL";
116 let result = cb(req_with_action_url(Some(url.to_string())));
117 match result {
118 Err(ProtocolError::AuthorizationFailed(msg)) => {
119 assert!(msg.contains("user verification required"));
120 assert!(msg.contains("DeleteRepository"));
121 assert!(
122 msg.contains(url),
123 "message must carry the deep-link URL: {msg}"
124 );
125 assert!(msg.contains("web app"));
126 }
127 other => panic!("expected a typed AuthorizationFailed error, got {other:?}"),
128 }
129 }
130}