Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
Expand Down
81 changes: 64 additions & 17 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ fn parse_envelope(bytes: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
#[derive(Clone)]
pub struct VTClient {
auth_token: String,
backend: crate::config::Backend,
}

impl VTClient {
Expand All @@ -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<Self> {
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.
Expand Down Expand Up @@ -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<u8>,
) -> Result<Option<Zeroizing<Vec<u8>>>> {
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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
}
Expand All @@ -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)
Expand Down Expand Up @@ -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) => {
Expand Down
75 changes: 75 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, String> {
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<Self> {
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)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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"));
Expand Down
Loading