Files
paseo/packages/website/src/routes/docs/security.tsx
T

290 lines
13 KiB
TypeScript

import { createFileRoute } from "@tanstack/react-router";
import { pageMeta } from "~/meta";
export const Route = createFileRoute("/docs/security")({
head: () => ({
meta: pageMeta(
"Security - Paseo Docs",
"Security model for Paseo: architecture overview, connection methods, relay encryption, and best practices.",
),
}),
component: Security,
});
function Callout({ children }: { children: React.ReactNode }) {
return (
<div className="bg-primary/10 border border-primary/30 rounded-lg p-4 text-white/80">
{children}
</div>
);
}
function Security() {
return (
<div className="space-y-10">
<div>
<h1 className="text-3xl font-medium font-title mb-4">Security</h1>
<p className="text-white/60 leading-relaxed">
Paseo follows a client-server architecture, similar to Docker. The daemon runs on your
machine and manages your coding agents. Clients (the mobile app, CLI, or web interface)
connect to the daemon to monitor and control those agents.
</p>
<p className="text-white/60 leading-relaxed mt-3">
Your code never leaves your machine. Paseo is a local-first tool that connects directly to
your development environment.
</p>
</div>
{/* Architecture Overview */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Architecture</h2>
<p className="text-white/60 leading-relaxed">
The Paseo daemon can run anywhere you want to execute agents: your laptop, a Mac Mini, a
VPS, or a Docker container. The daemon listens for connections and manages agent
lifecycles.
</p>
<p className="text-white/60 leading-relaxed">
Clients connect to the daemon over WebSocket. There are two ways to establish this
connection:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
<strong className="text-white/80">Relay connection (recommended)</strong> — The daemon
connects outbound to our relay server, and clients meet it there. No open ports
required.
</li>
<li>
<strong className="text-white/80">Direct connection</strong> — The daemon listens on a
network address and clients connect directly
</li>
</ul>
</section>
{/* Relay Connection */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Relay connections (recommended)</h2>
<p className="text-white/60 leading-relaxed">
The relay is the simplest way to connect from your phone. It requires no VPN setup, no
port forwarding, and no firewall configuration. The daemon can stay bound to localhost or
a socket file — it connects <em>outbound</em> to the relay, and your phone meets it there.
</p>
<Callout>
<strong>The relay is designed to be untrusted.</strong> All traffic between your phone and
daemon is end-to-end encrypted. The relay server cannot read your messages, see your code,
or modify traffic without detection. Even if the relay is compromised, your data remains
protected.
</Callout>
<h3 className="text-lg font-medium mt-6">How it works</h3>
<ol className="text-white/60 space-y-2 list-decimal list-inside">
<li>
The daemon generates a persistent ECDH keypair and stores it in{" "}
<code className="font-mono">$PASEO_HOME/daemon-keypair.json</code>
</li>
<li>
When you scan the QR code or click the pairing link, your phone receives the daemon's
public key
</li>
<li>
Your phone sends a handshake message with its own public key. The daemon will not accept
any commands until this handshake completes.
</li>
<li>
Both sides perform an ECDH key exchange to derive a shared secret. All subsequent
messages are encrypted with AES-256-GCM.
</li>
</ol>
<p className="text-white/60 leading-relaxed mt-3">
The relay sees only: IP addresses, timing, message sizes, and session IDs. It cannot read
message contents, forge messages, or derive encryption keys from observing the handshake.
</p>
<h3 className="text-lg font-medium mt-6">Why the relay can't attack you</h3>
<p className="text-white/60 leading-relaxed">
The daemon requires a valid cryptographic handshake before processing any commands. A
compromised relay cannot:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
<strong className="text-white/80">Send commands</strong> — Without your phone's private
key, it cannot complete the handshake
</li>
<li>
<strong className="text-white/80">Read your traffic</strong> — All messages are
encrypted with AES-256-GCM after the handshake
</li>
<li>
<strong className="text-white/80">Forge messages</strong> — GCM provides authenticated
encryption; tampered messages are rejected
</li>
<li>
<strong className="text-white/80">Replay old messages</strong> — Each session derives
fresh encryption keys
</li>
</ul>
<h3 className="text-lg font-medium mt-6">Trust model</h3>
<p className="text-white/60 leading-relaxed">
The QR code or pairing link is the trust anchor. It contains the daemon's public key,
which is required to establish the encrypted connection. Treat it like a password — don't
share it publicly.
</p>
<p className="text-white/60 leading-relaxed">
If you believe a pairing offer has been compromised, restart the daemon to generate a new
session ID and rotate the relay pairing.
</p>
</section>
{/* Direct Connection */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Direct connections</h2>
<p className="text-white/60 leading-relaxed">
By default, the daemon listens on <code className="font-mono">127.0.0.1:6767</code>{" "}
(localhost only). This is safe for local CLI usage but not reachable from your phone or
other devices.
</p>
<h3 className="text-lg font-medium mt-6">Socket file (CLI only)</h3>
<p className="text-white/60 leading-relaxed">
For maximum isolation, you can configure the daemon to listen on a Unix socket file
instead of a TCP port. This prevents any network access entirely — only processes on the
same machine can connect. The CLI supports this mode, but the mobile app and web interface
require a network connection.
</p>
<h3 className="text-lg font-medium mt-6">VPN access</h3>
<p className="text-white/60 leading-relaxed">
If you prefer direct connections over the relay, you can use a VPN like{" "}
<a
href="https://tailscale.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-white/80"
>
Tailscale
</a>
. Tailscale creates a private network between your devices, so you can access your daemon
without exposing it to the public internet.
</p>
<p className="text-white/60 leading-relaxed">To set this up:</p>
<ol className="text-white/60 space-y-2 list-decimal list-inside">
<li>
Install Tailscale on your machine and phone and join them to the same{" "}
<a
href="https://tailscale.com/kb/1136/tailnet"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-white/80"
>
tailnet
</a>
</li>
<li>
Configure the daemon to listen on your Tailscale IP (e.g.,{" "}
<code className="font-mono">100.x.y.z:6767</code>)
</li>
<li>
Add your Tailscale hostname to <code className="font-mono">allowedHosts</code> and{" "}
<code className="font-mono">cors.allowedOrigins</code>
</li>
<li>
Add the daemon as a direct connection in the Paseo app using the Tailscale address
</li>
</ol>
<h3 className="text-lg font-medium mt-6">Binding to 0.0.0.0</h3>
<div className="bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-4 text-white/80">
<strong>Warning:</strong> Binding to <code className="font-mono">0.0.0.0</code> makes the
daemon reachable on all network interfaces, including public Wi-Fi and local networks.
This can expose your daemon to unauthorized access. If you must bind to all interfaces,
ensure you have proper firewall rules and review your{" "}
<code className="font-mono">allowedHosts</code> configuration.
</div>
</section>
{/* DNS Rebinding Protection */}
<section className="space-y-4">
<h2 className="text-xl font-medium">DNS rebinding protection</h2>
<p className="text-white/60 leading-relaxed">
<strong className="text-white/80">CORS is not a complete security boundary.</strong> It
controls which browser origins can make requests, but does not prevent a malicious website
from resolving its domain to your local machine (DNS rebinding).
</p>
<p className="text-white/60 leading-relaxed">
Paseo uses a host allowlist to validate the <code className="font-mono">Host</code> header
on incoming requests. Requests with unrecognized hosts are rejected.
</p>
<p className="text-white/60 leading-relaxed">
Configure via <code className="font-mono">daemon.allowedHosts</code> in{" "}
<code className="font-mono">config.json</code>:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
Default (<code className="font-mono">[]</code>): allow{" "}
<code className="font-mono">localhost</code>,{" "}
<code className="font-mono">*.localhost</code>, and all IP addresses
</li>
<li>
<code className="font-mono">['.example.com']</code>: allow{" "}
<code className="font-mono">example.com</code> and any subdomain, plus defaults
</li>
<li>
<code className="font-mono">true</code>: allow any host (not recommended)
</li>
</ul>
</section>
{/* Agent Authentication */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Agent authentication</h2>
<p className="text-white/60 leading-relaxed">
Paseo wraps agent CLIs (Claude Code, Codex, OpenCode) but does not manage their
authentication. Each agent provider handles its own credentials:
</p>
<ul className="text-white/60 space-y-2 list-disc list-inside">
<li>
<strong className="text-white/80">Claude Code</strong> — authenticates via Anthropic's
OAuth flow, stored in <code className="font-mono">~/.claude/</code>
</li>
<li>
<strong className="text-white/80">Codex</strong> — uses your OpenAI API key or OAuth
session
</li>
<li>
<strong className="text-white/80">OpenCode</strong> — configured via provider-specific
API keys
</li>
</ul>
<p className="text-white/60 leading-relaxed">
Paseo never stores or transmits provider API keys. Agents run in your user context with
your existing credentials.
</p>
</section>
{/* Best Practices Summary */}
<section className="space-y-4">
<h2 className="text-xl font-medium">Recommendations</h2>
<ul className="text-white/60 space-y-3 list-disc list-inside">
<li>
<strong className="text-white/80">Use the relay</strong> for mobile access — it's the
simplest option and all traffic is end-to-end encrypted
</li>
<li>
<strong className="text-white/80">Treat the QR code like a password</strong> — anyone
with the pairing offer can connect to your daemon
</li>
<li>
<strong className="text-white/80">Never bind to 0.0.0.0</strong> unless you understand
the implications and have proper firewall rules
</li>
<li>
<strong className="text-white/80">Keep your daemon updated</strong> — security
improvements are released regularly
</li>
</ul>
</section>
</div>
);
}