Skip to main content

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}