diff --git a/CLAUDE.md b/CLAUDE.md index 3e3a334..a0cb5cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,17 +122,38 @@ Each transport path is gated by its own env vars. At least one must be set. | env | enables | required on | |---|---|---| -| `VT_AUTH` | SSH agent path (CLI ↔ local `vt ssh agent`, AES-GCM payload key) | macOS workstations running the agent; generated by `vt init` | +| `VT_AUTH` | SSH agent path (CLI ↔ local `vt ssh agent`, AES-GCM payload key) | macOS workstations running the agent; hosts that reach it over a forwarded socket; generated by `vt init` | | `VT_PASSKEY_URL` | CF passkey path — worker base URL, e.g. `https://vt-passkey.example.com` | every host using the phone-approval ceremony | | `VT_PASSKEY_TOKEN` | CF passkey path — HMAC key for `/api/challenge` request signing | every host using the phone-approval ceremony | +| `VT_BACKEND` | routing pin: `auto` (default) \| `agent` \| `passkey` | optional; see routing rules | `VT_PASSKEY_TOKEN` must match the `VT_AUTH_CF` wrangler secret on the worker. -Routing rules: +Routing rules (`VT_BACKEND=auto`, the default): - `VT_AUTH` set → try SSH agent first; fall back to passkey on recoverable errors. - `VT_AUTH` unset → skip agent, go straight to passkey. - Neither configured → CLI refuses at startup with an actionable error. +Pins (`src/config.rs::Backend`, validated in `VTClient::new`): +- `VT_BACKEND=agent` → agent only, never falls back to the passkey ceremony + (unreachable socket is an error instead of silently paging the phone). + Requires `VT_AUTH`. `sign@vt`'s decrypt-then-sign fallback still applies — + its decrypt honors the pin. +- `VT_BACKEND=passkey` → never probes the agent socket even when `VT_AUTH` is + set. Requires `VT_PASSKEY_URL`. `vt run` (agent-only) errors out under it. +- Invalid values fail loudly at startup, not silently as `auto`. + +Config-file hydration makes a config.toml `VT_AUTH` fully equivalent to the +env var: under `auto` it still means "probe the agent path first" — the CLI +verifies the path per call (socket connect; a non-vt agent answers the +extension with a failure) and falls back to the passkey ceremony cleanly, so +keeping `VT_AUTH` in a config shared across hosts is fine. The cost on a +host with no reachable vt agent is one failed local socket round-trip per +call; pin `VT_BACKEND=passkey` there if you want to skip even that. To force +the phone path for a single invocation: `VT_AUTH= vt …` (set-but-empty env +var beats the file and an empty token skips the agent) or +`VT_BACKEND=passkey vt …`. + **Config-file fallback.** Env vars are primary and always win. As a fallback, any unset `VT_*` variable is loaded from a flat TOML file at `~/.config/vt/config.toml` (override the path with `$VT_CONFIG`). Only keys diff --git a/config.example.toml b/config.example.toml index 9a915ea..876364b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -22,10 +22,23 @@ # VT_PASSKEY_TOKEN = "your-passkey-token" # --- SSH-agent path (macOS workstations running `vt ssh agent`) ------------- -# When set, the CLI tries the local SSH agent (Touch ID) first and falls back -# to the passkey ceremony. Leave unset on Linux servers. SENSITIVE. +# When set (here or in the env — both are equivalent), the CLI verifies the +# agent path first (local socket, or one forwarded with `ssh -A`) and falls +# back to the passkey ceremony when it's unreachable or not a vt agent. Safe +# to keep in a config synced across hosts; on agent-less hosts each call just +# pays one failed local socket probe (pin VT_BACKEND=passkey to skip it). +# SENSITIVE. # VT_AUTH = "your-ssh-agent-token" +# --- Routing pin (optional) -------------------------------------------------- +# auto (default): agent first when VT_AUTH is set, passkey fallback on +# recoverable errors. +# agent: agent only — never fall back to the phone ceremony. +# passkey: never probe the agent socket, even when VT_AUTH is set. +# One-shot override without editing this file: `VT_BACKEND=passkey vt …`, +# or `VT_AUTH= vt …` (empty env var beats the file and skips the agent). +# VT_BACKEND = "auto" + # --- git SSH signing identity (optional) ------------------------------------ # Encrypted private key record and its matching public key. # VT_GIT_SSH_PRIVATE_KEY = "vt://0..." diff --git a/src/client.rs b/src/client.rs index cb421d2..dbc27df 100644 --- a/src/client.rs +++ b/src/client.rs @@ -160,6 +160,7 @@ fn parse_envelope(bytes: &[u8]) -> Result>> { #[derive(Clone)] pub struct VTClient { auth_token: String, + backend: crate::config::Backend, } impl VTClient { @@ -169,23 +170,38 @@ impl VTClient { /// - SSH agent path: `VT_AUTH` (passed in as `auth_token`) /// - CF passkey path: `VT_PASSKEY_URL` + `VT_PASSKEY_TOKEN` /// - /// If neither is set we fail here rather than deep inside the routing - /// code, so the user sees a single actionable error. + /// `VT_BACKEND` (auto | agent | passkey) pins the routing; its + /// requirements are validated here too, so the user sees a single + /// actionable error rather than a failure deep inside the routing code. pub fn new(auth_token: String) -> Result { + let backend = crate::config::Backend::from_env()?; + match backend { + crate::config::Backend::Agent if auth_token.is_empty() => { + anyhow::bail!("VT_BACKEND=agent requires VT_AUTH for the SSH agent path"); + } + crate::config::Backend::Passkey if std::env::var("VT_PASSKEY_URL").is_err() => { + anyhow::bail!( + "VT_BACKEND=passkey requires VT_PASSKEY_URL + VT_PASSKEY_TOKEN \ + for the phone passkey ceremony" + ); + } + _ => {} + } if auth_token.is_empty() && std::env::var("VT_PASSKEY_URL").is_err() { anyhow::bail!( "no decryption path configured — set VT_AUTH for the SSH agent path, \ or VT_PASSKEY_URL + VT_PASSKEY_TOKEN for the phone passkey ceremony" ); } - Ok(VTClient { auth_token }) + Ok(VTClient { auth_token, backend }) } - /// True when the SSH-agent path is configured (`VT_AUTH` was set). Gates - /// agent-identity discovery in `vt ssh connect`: `sign@vt` needs this key, - /// so discovery only makes sense when it's present. + /// True when the SSH-agent path is usable: `VT_AUTH` set and not pinned + /// away by `VT_BACKEND=passkey`. Gates agent-identity discovery in + /// `vt ssh connect`: `sign@vt` needs this key, so discovery only makes + /// sense when the agent path is in play. pub(crate) fn has_auth_token(&self) -> bool { - !self.auth_token.is_empty() + !self.auth_token.is_empty() && self.backend != crate::config::Backend::Passkey } /// Try to send an extension request via the SSH agent socket. @@ -307,24 +323,41 @@ impl VTClient { .map_err(|e| transport(anyhow::anyhow!("{}", e))) } - /// Wrap `try_agent_extension` with the `auto`-mode fallback policy: - /// translate `Ok(None)` (socket missing/refused) and recoverable - /// `Err(_)` (per [`should_fallback_to_cf`]) into a single `Ok(None)` so - /// callers can route to the CF path. Non-recoverable errors propagate. + /// Wrap `try_agent_extension` with the routing policy: + /// + /// - `VT_BACKEND=passkey` → `Ok(None)` immediately, without probing the + /// agent socket (a shared config.toml may set `VT_AUTH` on hosts where + /// `$SSH_AUTH_SOCK` points at an unrelated ssh-agent). + /// - `VT_BACKEND=agent` → never fall back: socket-missing becomes an + /// actionable error, agent errors propagate unfiltered. + /// - `auto` (default) → translate `Ok(None)` (socket missing/refused) and + /// recoverable `Err(_)` (per [`should_fallback_to_cf`]) into a single + /// `Ok(None)` so callers route to the CF path; non-recoverable errors + /// propagate. #[cfg(unix)] async fn agent_call_or_fallback( auth_token: String, + backend: crate::config::Backend, name: &'static str, payload: Vec, ) -> Result>>> { + use crate::config::Backend; + if backend == Backend::Passkey { + return Ok(None); + } let result = tokio::task::spawn_blocking(move || { Self::try_agent_extension(&auth_token, name, &payload) }) .await?; match result { - Ok(some_or_none) => Ok(some_or_none), + Ok(Some(bytes)) => Ok(Some(bytes)), + Ok(None) if backend == Backend::Agent => Err(anyhow::anyhow!( + "SSH agent socket unavailable and VT_BACKEND=agent forbids the \ + passkey fallback — is the vt agent running / forwarded?" + )), + Ok(None) => Ok(None), Err(e) => { - if should_fallback_to_cf(&e) { + if backend != Backend::Agent && should_fallback_to_cf(&e) { // Keep the raw error out of default output (it reads as a // scary internal failure); the CF path prints a clean, // user-facing fallback line. `RUST_LOG=debug` still surfaces @@ -352,7 +385,7 @@ impl VTClient { let payload = serde_json::to_vec(&req)?; let auth_token = self.auth_token.clone(); let result = - Self::agent_call_or_fallback(auth_token, "encrypt@vt", payload).await?; + Self::agent_call_or_fallback(auth_token, self.backend, "encrypt@vt", payload).await?; let bytes = match result { Some(b) => b, None => return Self::cf_encrypt(items, &get_hostname()).await, @@ -469,7 +502,7 @@ impl VTClient { let payload = serde_json::to_vec(&wire)?; let auth_token = self.auth_token.clone(); let result = - Self::agent_call_or_fallback(auth_token, "decrypt@vt", payload).await?; + Self::agent_call_or_fallback(auth_token, self.backend, "decrypt@vt", payload).await?; let bytes = match result { Some(b) => b, None => return Self::cf_decrypt(host, command, urls).await, @@ -571,6 +604,15 @@ impl VTClient { flags, meta: cf::collect_client_meta(), }; + // VT_BACKEND=passkey pins routing away from the agent: skip the socket + // probe and return the "fall back" signal — the caller's + // decrypt-then-sign fallback routes its decrypt through this client + // again, which then takes the passkey ceremony. `agent` mode is NOT + // special-cased here for the same reason: the fallback's decrypt still + // honors the agent-only pin. + if self.backend == crate::config::Backend::Passkey { + return Ok(None); + } let payload = serde_json::to_vec(&req)?; let auth_token = self.auth_token.clone(); // Same blocking + classification as `agent_call_or_fallback`, but @@ -694,6 +736,11 @@ impl VTClient { is no phone-passkey fallback for run@vt)" ); } + if self.backend == crate::config::Backend::Passkey { + anyhow::bail!( + "vt run is agent-only, but VT_BACKEND=passkey disables the agent path" + ); + } if argv.is_empty() { anyhow::bail!("vt run: argv is empty"); } @@ -706,7 +753,7 @@ impl VTClient { let payload = serde_json::to_vec(&req)?; let auth_token = self.auth_token.clone(); let result = - Self::agent_call_or_fallback(auth_token, "run@vt", payload).await?; + Self::agent_call_or_fallback(auth_token, self.backend, "run@vt", payload).await?; match result { Some(bytes) => { let res: RunRes = serde_json::from_slice(&bytes) @@ -737,7 +784,7 @@ impl VTClient { let payload = serde_json::to_vec(&req)?; let auth_token = self.auth_token.clone(); let result = - Self::agent_call_or_fallback(auth_token, "auth@vt", payload).await?; + Self::agent_call_or_fallback(auth_token, self.backend, "auth@vt", payload).await?; match result { Some(bytes) => { diff --git a/src/config.rs b/src/config.rs index 994b601..5b835b1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -122,6 +122,67 @@ pub fn hydrate_env_from_file() { } } +// --------------------------------------------------------------------------- +// Transport-path routing preference (VT_BACKEND) +// --------------------------------------------------------------------------- + +/// Routing preference between the two transport paths, read from +/// `VT_BACKEND` (env var, or config.toml via the hydration above). +/// +/// Historically the selector was implicit: `VT_AUTH` present → try the SSH +/// agent first. With the config-file fallback, `VT_AUTH` presence is ambient +/// (a copied config.toml silently enables agent probing), so `VT_BACKEND` +/// makes the intent explicit per host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Backend { + /// Try the SSH agent when `VT_AUTH` is set; fall back to the passkey + /// ceremony on recoverable errors. The historical (and default) behavior. + #[default] + Auto, + /// SSH agent only — never fall back to the passkey ceremony. Errors out + /// when the agent is unreachable instead of silently paging the phone. + Agent, + /// Passkey ceremony only — never probe the agent socket, even when + /// `VT_AUTH` is set (e.g. a config.toml shared with agent-reaching hosts). + Passkey, +} + +impl Backend { + /// Parse a `VT_BACKEND` value. Empty/whitespace counts as unset (`Auto`); + /// anything else must match exactly, so a typo fails loudly instead of + /// silently routing to the wrong path. + pub fn parse(s: &str) -> Result { + match s.trim().to_ascii_lowercase().as_str() { + "" | "auto" => Ok(Backend::Auto), + "agent" => Ok(Backend::Agent), + "passkey" => Ok(Backend::Passkey), + other => Err(format!( + "invalid VT_BACKEND '{}': expected auto, agent, or passkey", + other + )), + } + } + + /// Read `VT_BACKEND` from the environment (already hydrated from the + /// config file). Unset → `Auto`. + pub fn from_env() -> anyhow::Result { + match std::env::var("VT_BACKEND") { + Err(_) => Ok(Backend::Auto), + Ok(v) => Self::parse(&v).map_err(|e| anyhow::anyhow!(e)), + } + } +} + +impl std::fmt::Display for Backend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Backend::Auto => write!(f, "auto"), + Backend::Agent => write!(f, "agent"), + Backend::Passkey => write!(f, "passkey"), + } + } +} + // --------------------------------------------------------------------------- // Agent config (dedicated file: ~/.config/vt/agent.toml) // --------------------------------------------------------------------------- @@ -264,6 +325,20 @@ pub fn load_agent_config() -> HookConfig { mod tests { use super::*; + #[test] + fn backend_parse() { + assert_eq!(Backend::parse("auto"), Ok(Backend::Auto)); + assert_eq!(Backend::parse("agent"), Ok(Backend::Agent)); + assert_eq!(Backend::parse("passkey"), Ok(Backend::Passkey)); + // Case-insensitive + trimmed; empty counts as unset. + assert_eq!(Backend::parse(" Passkey "), Ok(Backend::Passkey)); + assert_eq!(Backend::parse(""), Ok(Backend::Auto)); + assert_eq!(Backend::parse(" "), Ok(Backend::Auto)); + // Typos fail loudly instead of silently routing to the wrong path. + assert!(Backend::parse("pass-key").is_err()); + assert!(Backend::parse("cf").is_err()); + } + #[test] fn allowed_keys() { assert!(is_allowed_key("VT_AUTH"));