bssh-agent
v1.0.0
Published
browser-ssh-agent: SSH agent forwarding over WebSocket — keys live in the browser, signing is relayed to a Node.js SSH client
Downloads
258
Maintainers
Readme
browser-ssh-agent
SSH agent forwarding over WebSocket: the private key lives in a browser tab,
signing is relayed to a Node.js SSH client. A reimplementation of ssh -A
where the "agent" is a paired browser session instead of a local socket.
How it works
Pairing (step 2 below) happens once; using ssh/git (step 3) then works
for as long as the browser tab stays connected, with no further setup.
sequenceDiagram
actor User
participant Browser as Browser tab<br/>(holds the key)
participant Server as Your app<br/>(bssh-agent)
participant SSH as ssh / git<br/>(local CLI)
participant Remote as Remote SSH host
rect rgb(245,245,245)
Note over Server: 1. Start the server
Server->>Server: attachTo() + startUnixSocket()
Server->>Server: createPairingLink() -> pairing URL
end
rect rgb(245,245,245)
Note over User,Server: 2. Pair the browser (once)
Server-->>User: Show "Pair your key" link on the page
User->>Browser: Click the link
User->>Browser: Select key file + enter passphrase
Browser->>Browser: Decrypt key locally - never leaves the tab
Browser->>Server: WebSocket connect + hello(token)
Server-->>Browser: hello-ack (paired)
end
rect rgb(245,245,245)
Note over User,Remote: 3. Use ssh / git - any time after pairing
Server->>SSH: spawn ssh/git via agentServer.env()
SSH->>Server: via SSH_AUTH_SOCK: list identities
Server->>Browser: relay request
Browser-->>Server: public key
Server-->>SSH: public key
SSH->>Remote: offer publickey auth
Remote-->>SSH: sign this challenge
SSH->>Server: sign request
Server->>Browser: relay request
Browser-->>User: confirm this sign? (optional)
Browser-->>Server: signature
Server-->>SSH: signature
SSH->>Remote: signed challenge
Remote-->>SSH: authenticated
SSH-->>Server: command output
endStatus
The core of the library relays signing requests from a paired browser
session to a real SSH_AUTH_SOCK Unix domain socket
(agentServer.startUnixSocket()). Any ssh/git/rsync/scp process that
picks up that socket authenticates using a key that only ever exists in the
browser tab — never on the server, never on disk.
There are two ways to run the server side, and a widget that works with either:
bssh-agentCLI — a standalone,ssh-agent-style daemon: no host app required,SSH_AUTH_SOCKlands directly in your shell. See Standalone: thebssh-agentCLI.AgentServer, embedded in your own app — attach to an HTTP(S) server you're already running, and spreadagentServer.env()intochild_process.spawn()calls your app makes. See Embedded: attach to your own HTTP server.<bssh-agent-pairing>widget (bssh-agent/widget) — a drop-in Web Component wrapping the browser-side primitives below, so a host page needs only a<script>tag and a custom element instead of hand-writing a key form and confirm-sign dialog. Works with either mode above. See Browser, using the drop-in widget.
v1 supports Ed25519 keys only. RSA/ECDSA can be added by implementing the
Signer interface (src/browser/signers/) — no protocol change required.
There's also a lower-level way to integrate directly with ssh2's own
Client API instead of spawning external CLI tools — see Advanced.
Installation
npm install bssh-agentThis package is ESM-only (import, not require()). It publishes four
subpath exports:
bssh-agent/server— the Node.js server-side API (AgentServer, ...).bssh-agent/browser— browser-side primitives (loadKeyFromFile,connectAgent, ...).bssh-agent/widget— the drop-in<bssh-agent-pairing>Web Component, built on top ofbssh-agent/browser.bssh-agent/shared— protocol types shared by all three of the above.
It also installs a bssh-agent CLI binary — run it via npx bssh-agent, or
just bssh-agent once the package is installed.
See the API reference for the exhaustive list of every export, option, and event across all four subpaths.
Usage
Standalone: the bssh-agent CLI
A ready-to-run daemon, ssh-agent-style — no host app or code changes
required. Best when a human is typing ssh/git/rsync directly into
their own shell, or when you just want to try the library out:
eval "$(bssh-agent)"This starts a background daemon, prints a pairing URL to stderr (and tries to
open it in your default browser, unless --no-browser or a remote/SSH
session is detected), and evals SSH_AUTH_SOCK/SSH_AGENT_PID into your
current shell. Once you've paired a key via the opened page (using the
<bssh-agent-pairing> widget below), every subsequent ssh/git/rsync in
that shell picks up SSH_AUTH_SOCK for free — no code changes in any host
app required. If a command runs before you finish pairing, it just fails
auth cleanly and can be retried, the same way it would against a locked
GUI keychain agent.
bssh-agent -k # stop the daemon and unset the env vars: eval "$(bssh-agent -k)"See the API reference for the full
flag listing (-D/--foreground, --name, --force, --port, --host,
--runtime-dir, ...).
Unlike real ssh-agent, bssh-agent -k must be able to find a running
daemon from a different shell session than the one that started it, so it
keeps a small state file (pid, socket path, port) under its runtime
directory rather than relying solely on SSH_AGENT_PID.
Embedded: attach to your own HTTP server
Wire AgentServer into an app you're already building: attach to its
existing HTTP(S) server and start the Unix socket transport.
import { createServer } from 'node:http';
import { spawn } from 'node:child_process';
import { AgentServer } from 'bssh-agent/server';
const httpServer = createServer(/* your app's existing request handler */);
const agentServer = new AgentServer();
agentServer.attachTo(httpServer, '/ws');
await agentServer.startUnixSocket();
// Render this as a link/button on a page the user is already viewing in
// their own browser (e.g. `<a href={url}>Pair your SSH key</a>`) — no need
// to relay it through any other channel. Never log it persistently — the
// pairing token lives in the fragment on purpose.
const { url } = agentServer.createPairingLink('https://your-app.example/pair');
agentServer.on('session-paired', () => {
spawn('git', ['clone', '[email protected]:you/repo.git'], {
env: { ...process.env, ...agentServer.env() },
stdio: 'inherit',
});
});
// Your app's own server startup — not part of this library. attachTo()
// only registers an 'upgrade' listener; it doesn't start listening itself.
httpServer.listen(8787);The /ws path above matches <bssh-agent-pairing>'s zero-config default —
see the widget section below. agentServer.listen(port) is also available
as a standalone alternative to attachTo(); see the
API reference.
Browser
Served from the page createPairingLink's baseUrl points at:
import { loadKeyFromFile, connectAgent } from 'bssh-agent/browser';
const token = new URLSearchParams(location.hash.slice(1)).get('token')!;
const key = await loadKeyFromFile(fileInput.files[0], passphraseInput.value);
const connection = connectAgent({
wsUrl: 'wss://your-app.example:8787/ws',
token,
key,
confirmSign: async ({ comment, fingerprint }) =>
confirm(`Sign with ${comment} (${fingerprint})?`),
});Browser, using the drop-in widget
<bssh-agent-pairing> wraps loadKeyFromText + connectAgent above into a
single custom element with its own key-loading form and confirm-sign UI —
add a <script> tag and the tag itself, no hand-written form or dialog
required:
<script type="module" src="https://unpkg.com/[email protected]/dist/widget/index.js"></script>
<bssh-agent-pairing></bssh-agent-pairing>Pin the version (as above) rather than floating on @latest — a breaking
change in a later release shouldn't silently change what a copy-pasted
<script> tag loads. Self-hosting dist/widget/index.js (e.g. from your
own app's static assets after npm install) works the same way and avoids
a runtime dependency on a third-party CDN.
By default it reads the pairing token from location.hash (matching
createPairingLink()'s output) and derives its WebSocket URL from
${location.protocol}://${location.host}/ws — matching the attachTo(httpServer, '/ws')
example above. Pass the ws-url attribute, or set the token/wsUrl
properties from JS, if your host page needs to supply them explicitly (e.g.
a different path, or embedded in an iframe).
It emits status-change, paired, error, sign-request, and
key-forgotten CustomEvents so a host page can observe activity without
touching bssh-agent/browser directly:
document.querySelector('bssh-agent-pairing').addEventListener('paired', () => {
console.log('key paired and ready');
});It zeroizes the decrypted key automatically on disconnect (and via its built-in "Forget key" button) — see Security notes — so the passphrase is always required again after a drop. What it does not require again is the file: the widget keeps the still-encrypted key file text cached in memory for the life of the page, so reconnecting after a dropped WebSocket (network blip, laptop sleep, tab backgrounded — anything short of actually closing the tab or reloading the page) shows a passphrase-only form instead of the full file picker. A "Use a different file" button is always available alongside it to discard the cache and go back to picking a file, and the explicit "Forget key" button always clears both the decrypted key and the cached file — there's no way to reconnect without the passphrase, only ways to avoid re-selecting the file. Caching the encrypted text costs nothing security-wise: it's exactly what the passphrase already protects, and the passphrase itself is never cached. Reconnecting still needs a fresh pairing token from your host app (tokens are single-use, and there's no session resumption) — only the key-loading step is skipped.
By default every sign request is approved automatically, with no prompt —
this matches real ssh-agent's own default (ssh -A doesn't ask
per-signature either). Set require-confirm="true" to show a built-in
approve/deny prompt before each signature instead (the same trade-off real
ssh-agent's optional -c/confirm mode offers — see
Security notes), or set the confirmSign property to
supply your own UI instead of the built-in one.
See the API reference for the full attribute/property/event listing.
Try it in Docker
A self-contained way to try the standalone CLI without installing Node locally:
docker build -t bssh-agent-demo .
docker run --rm --name bssh-agent-demo -p 8787:8787 bssh-agent-demo
docker logs bssh-agent-demoThe last command prints a Pairing URL: http://127.0.0.1:8787/#token=...
line — open it in your own desktop browser, not inside the container. Its
file picker reads a private key straight off your own machine's disk; the
container never sees it, only the public key and signatures that cross the
WebSocket afterward (see How it works). A throwaway keypair
works fine for a first try. Once paired, signing happens without a prompt
(the default — see Security notes for the trade-off, and
--require-confirm if you'd rather approve each one).
Once paired, run a real ssh command through it from inside the container:
docker exec -it bssh-agent-demo sh -c \
'. /run/bssh-agent/env.sh && ssh -o StrictHostKeyChecking=accept-new -T [email protected]'Substitute your own remote host for [email protected] — it's used here only
because it needs no extra server setup. The container binds its pairing page
to 0.0.0.0 via --host so Docker's own port-publishing can reach it;
outside a container the CLI's default stays loopback-only — see Security
notes.
Advanced
Using agentServer.agent() directly with ssh2.Client
For apps that make SSH connections themselves through ssh2's own Client
API (exec/sftp/forwardOut, agent-forwarded hops to further remote
hosts) instead of spawning external ssh/git/rsync CLI processes,
agentServer.agent() returns a ssh2-compatible BaseAgent you can pass
directly:
agent: agentServer.agent(),
agentForward: true, // forwards further if the target host hops onwardThis integration path is real and tested (agentServer.agent() has been
usable since the project's earliest version), but isn't written up here in
full yet — see src/server/transports/inProcessAgent.ts and the
API reference in the meantime.
Delivering the pairing link across devices (QR code, printed link, ...)
The Embedded usage example assumes the simplest and safest case:
the process minting the pairing link is the web app the user is already
viewing in their own browser, so createPairingLink()'s URL can just be
rendered as a link/button on that page — no separate delivery channel
needed at all.
That assumption breaks down when whatever calls createPairingLink() has
no browser of its own to render a link in — most notably the bssh-agent
CLI (see Standalone), which may be running
on a remote/headless machine you've SSH'd into. In that case the URL has to
reach a different device's browser somehow: printing it for the user to
copy-paste, or rendering it as a QR code for a phone to scan, are the two
common approaches.
Both introduce a risk the <a href> case doesn't have: the URL — with its
live, single-use token — now exists as something that can be captured and
retained: a QR code image saved to a file, a printed page filed away, a
terminal session that gets logged. That's the same risk class as never
logging the pairing link persistently — a saved QR code
image is a persistent log of the token. The token's short default TTL (5
minutes) bounds how long such exposure actually matters, but don't rely on
that alone — treat any QR code or printed copy as something to discard once
the pairing attempt is done, the same way you'd treat a password written on
a sticky note.
Unattended access (no human present)
bssh-agent requires a human to keep a browser tab open for every signing
operation (see How it works) — it isn't a fit for
automation that must authenticate with nobody present. Two established
alternatives apply instead, depending on what you control:
- SSH certificates (
TrustedUserCAKeys) — requires control over the remote host'ssshdconfiguration, i.e. administering that host, not just holding a user account on it. - A dedicated keypair for the app server — works with an ordinary user
account on the remote host, no root required. See the
dedicated-key bootstrap guide for the
exact commands and
authorized_keysrestrictions to use.
Known issues
Upstream ssh2 bug worked around: ssh2's AgentProtocol (server
mode) mishandles the SSH_AGENTC_EXTENSION probe modern OpenSSH clients
(8.9+) send before listing identities — it replies correctly but fails to
skip the message's payload, desyncing the wire framing for everything after
and silently wedging the whole agent connection. UnixSocketAgent filters
and answers these probes itself before handing other messages to
AgentProtocol; see the comment above pipeFilteringUnsupportedRequests in
src/server/transports/unixSocketAgent.ts for details. Confirmed present
through [email protected] (latest as of writing).
Security notes
- Key material lives in the browser tab's JS heap for the session. This
is the fundamental trade-off of the design (avoiding the server holding the
key) and is not fully eliminable — mitigate with a minimal, dependency-light
pairing page, a strict CSP (
script-src 'self', no inline/eval), a dedicated tab rather than an iframe, and callKeyHandle.zeroize()on disconnect/idle. - Use
wss://off-loopback. Plainws://is only acceptable to127.0.0.1. - By default, every sign request is approved automatically — this
matches real
ssh-agent's own default (ssh -Adoesn't prompt per-signature either). The trade-off it shares with real agent forwarding: if the server relaying requests is ever compromised, an attacker can authenticate as you with the paired key for as long as the browser tab stays connected. (The agent protocol never reveals which remote host a challenge is for, only the key fingerprint, so there's no way to spot this from the request alone either way.) Setrequire-confirm="true"on the widget — or--require-confirmon the CLI — to show an approve/deny prompt for every signature instead, the same mitigation realssh-agent's optional-c/confirm mode offers; or supplyconfirmSigndirectly for your own UI. - Pairing tokens are single-use and go in the URL fragment, never a query parameter (proxies commonly log those) and never a persistent log file.
- The Unix socket file is a local privilege boundary: anything on the
machine that can connect to it gets full "sign arbitrary challenges with
the loaded key" power, equivalent in trust to real agent forwarding. It's
created at a per-run unguessable path with
0600permissions. No Windows named-pipe support — Unix domain socket only. - The CLI's self-served pairing page binds
127.0.0.1only by default, never0.0.0.0. Using it over SSH into a remote box requires you tossh -Lthe pairing port yourself — the CLI skips auto-opening a browser whenSSH_CONNECTION/SSH_TTYsuggest a remote/headless session.--hostoverrides the bind address (e.g.--host 0.0.0.0so Docker's own port-publishing can reach it — see Try it in Docker); only widen it within a network boundary you already trust, since it removes the loopback protection. - The widget caches the encrypted key file's text in memory across a disconnect, so reconnecting only asks for the passphrase, not the file — see Browser, using the drop-in widget. This is a deliberate, low-risk convenience: the cached text is exactly what the passphrase already protects, so caching it doesn't expose anything the passphrase-check doesn't already guard, and it's discarded entirely by "Forget key," "Use a different file," or a page reload. It is not the same as caching the decrypted key or the passphrase — those are never retained past a disconnect. If your threat model requires that even the encrypted file be forgotten immediately on disconnect, don't rely on the widget's default behavior here; this is the "least security-risk" convenience option, not a no-op.
Reference
See docs/REFERENCE.md for the exhaustive API reference — every export across all four subpaths, plus the full CLI flag listing — as opposed to this README's narrative getting-started coverage.
Development
npm install
npm run typecheck
npm test
npm run build
npm run test:cli # builds, then exercises the real bssh-agent binary end-to-end