Let agents securely share their machines.
An agent working on a Linux box can hand another agent a shell on it — with no
account, no key exchange, and no human in the loop. The recipient needs nothing
installed: a URL, curl, and ssh.
A capability is redeemed once, and the certificate it issues is good for thirty minutes by default. "Once" is the redemption, not the session: one certificate opens as many connections as the visitor wants inside its window. When the window closes, no new connection is possible, and every process of a session still open is terminated inside the root-owned cgroup the host placed it in.
The coordination service is a router, not a trust root. It never holds a private key or a grant secret, and compromising it entirely is not enough to obtain access to any machine.
The agent giving access, on the machine it is sharing:
curl -s --unix-socket /run/grantd/owner/owner.sock \
-X POST http://localhost/grants \
-H 'content-type: application/json' -d '{"ttl_seconds":1800}'{ "grant_id": "g_4mmhs4dd4ww5qvnb",
"expires_at": 1788283057,
"capability_url": "https://api.grantd.dev/g/h_ubk4.../g_4mmh...#uJN2fx..." }Send that URL to the recipient over any channel you trust.
The agent receiving access, anywhere on the internet:
curl -sO https://api.grantd.dev/redeem.sh
GRANTD_CAPABILITY='https://api.grantd.dev/g/h_ubk4.../g_4mmh...#uJN2fx...' sh redeem.shIt verifies the host's signed registration, generates a throwaway SSH key,
registers an identity, redeems the capability, checks the certificate against
the host's CA, and prints the ssh command. Thirty minutes later the
certificate stops authenticating new connections, and the host terminates every
process of any session still running under it.
The URL can also be passed as an argument. On a shared machine prefer the
variable or stdin (sh redeem.sh -), because other users can read command
line arguments.
Every other way to do this is worse in a specific way.
Sharing a private key has no expiry and no attribution. You cannot take it back, and you cannot tell afterwards who used it.
Adding an authorized_keys entry requires the recipient to already have a
key you trust, and someone has to remember to remove it. Nobody ever does.
Teleport, Boundary, Cloudflare Access, step-ca all issue short-lived SSH certificates and all do it well — but every one assumes an identity provider, an admin who configured a role, and a principal that existed in a directory beforehand. That is the right model for employees. It does not describe an agent that was created ten minutes ago and needs a shell for twenty.
grantd is for the case where the recipient has no account anywhere, is unknown to everyone thirty seconds before connecting, and should be unknown again an hour later.
OWNER MACHINE grantd service VISITING AGENT
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ grantd │◄──WSS─────────►│ Worker │◄────HTTPS─────►│ ephemeral │
│ (network) │ │ HostDO │ │ SSH keypair │
│ │ │ │ AgentDO │ │ agent id │
│ unix socket │ └──────────┘ └──────┬───────┘
│ ▼ │ │
│ grant-signer │ host identity key │
│ (no network)│ SSH CA key │
│ │ │ grant secrets │
└──────┼───────┘ │
▼ │
sshd ◄──────────────── direct SSH, never proxied ─────────────┘
- The host generates its own SSH CA at install time and tells
sshdto trust it. The private key never leaves the machine. - Creating a grant mints a random 32-byte secret, stored locally. Only signed public metadata is published — never the secret.
- The secret travels in the URL fragment. Browsers and HTTP clients do not transmit fragments, so the service receives the path and never the capability.
- Redeeming proves possession by HMAC over the request, keyed with that secret. The host verifies it locally and signs an SSH certificate.
- Before redeeming, the agent fetches the host's signed record, checks that
the identity key in it hashes to the
host_idits capability URL already names, and verifies the signature. It takes the address and the SSH host key from that record and pins the host key. - The agent connects directly to the host. SSH traffic never touches the coordination service.
| Stays on the host | Stays with the visiting agent |
|---|---|
| SSH CA private key | ephemeral SSH private key |
| host identity private key | agent identity private key |
| grant secrets |
A compromised coordination plane — Workers, Durable Objects, deployment
credentials, all of it — cannot fabricate a grant, extend an expiry, substitute
an SSH key, change which account the certificate is for, cause a second
certificate to be issued, or send the visitor to a machine the host did not
name. Each of those is a test in
go/tests/adversarial, run against a service written to
be actively malicious.
The visitor trusts the service for nothing. The host id in the capability URL is a hash of the host's identity key, so the redeemer fetches the host's signed registration, verifies it against that id, and takes the hostname, port, user, and SSH CA from the signed record. The certificate it receives must come from that CA, for its own key, for that user.
What the service is trusted for. Two things, and the README is explicit
about both. It delivers install and redeem.sh when you fetch them from the
service origin, and it routes traffic. If you do not want to trust it for code
delivery, take both scripts from a pinned release of this repository instead.
The installer then verifies every binary it runs against the release signature
embedded in it.
On the machine you want to share:
curl -sO https://api.grantd.dev/install
sudo bash install --origin https://api.grantd.dev \
--ssh-user <an unprivileged account> --hostname <the address visitors dial>If the machine has no stable address to hand out, swap --hostname for
--dns-suffix and let the service publish one for it:
sudo bash install --origin https://api.grantd.dev \
--ssh-user <an unprivileged account> --dns-suffix hosts.example.comThe name is derived from the host id, so it is this machine's and no other's,
and the record is never proxied — SSH still goes direct. This requires the
service to be configured for it; see
cloudflare/README.md.
Some sandboxes — Claude's among them — allow HTTP over TLS and nothing else. A gateway there will carry a TLS handshake and reset a plaintext SSH identification string, so no port helps: 22 is blocked and 443 is inspected.
For those, the host can serve the session over a WebSocket on 443:
sudo ./bridge.sh --email you@example.comThat installs nginx and certbot, obtains a certificate for the machine's
--dns-suffix name, and runs grantd-bridge, which copies bytes between a
WebSocket and 127.0.0.1:22 and does nothing else — the target is compiled
in, so no request can move it. Visitors need no new flags: redeem.sh probes
the direct path first, and falls back to the bridge only when it must.
The bridge changes the pipe, not the trust. TLS terminates on your host, so
the coordination service is still not in the path and still never sees a byte
of the session; the visitor still pins the host key and still presents a
certificate your CA issued. What does change is that sshd sees every bridged
session as coming from 127.0.0.1, so per-source controls like fail2ban
cannot see a bridged visitor — nginx's connection and rate limits replace
them.
Fetching install from the service means trusting the service to deliver
that one script. If that is not acceptable, use
install/install.sh from a tagged release of this
repository. Either way, the script verifies the release signature and every
binary hash before it runs anything, refuses to start if sshd -t already
fails, gates every sshd reload on sshd -t, and restores the previous SSH
configuration if any step fails. The signed manifest also binds the release
version, so an origin cannot serve an older release under a newer name.
root cannot be enrolled. A visiting agent's blast radius is bounded by the
account you choose, and enrolling root removes the bound.
Removal destroys the CA private key, after which no certificate it ever signed is accepted again:
sudo ./uninstall.sh --yesHost: Linux, systemd, OpenSSH with an ed25519 host key
(/etc/ssh/ssh_host_ed25519_key.pub, which every distribution generates on
install and ssh-keygen -A creates), amd64 or arm64, outbound HTTPS.
Visitor: a path to the host's SSH port, plus either curl, ssh and
OpenSSL 3.x, or Node 18 and an SSH client library.
That first requirement is the one that disqualifies most agent sandboxes, so it
is worth stating plainly. SSH is never proxied through the coordination
service, so the visitor must be able to open a connection to the host itself.
Raw outbound TCP satisfies this. So does an HTTP CONNECT proxy named in
HTTPS_PROXY, because CONNECT builds a byte pipe and SSH runs over it
unchanged. Hosts listed in NO_PROXY are reached directly, as curl does. Many sandboxes allow CONNECT only to port 443, which is why the
installer takes --listen-port 443.
Both redeemers check that path before they spend the grant and refuse with an explanation rather than handing back a certificate that cannot be used.
If a sandbox has a JavaScript runtime and no package manager, use
install/redeem.mjs. It needs no dependencies and no
binaries, not even openssl or ssh-keygen.
macOS ships LibreSSL as openssl, which has no Ed25519 at all. redeem.sh
looks for a capable binary in the usual places and says so plainly if there is
none; set GRANTD_OPENSSL to override.
This is designed to be adopted without a human present, in both directions.
If you were handed a capability URL, fetch its path for instructions written for you:
curl https://api.grantd.dev/g/<host_id>/<grant_id>You get the exact requests to make, in plain text. There is no SDK, and
install/redeem.sh is a reference implementation in POSIX
shell — if you would rather implement the protocol yourself,
the whitepaper specifies every signed byte and
protocol/test-vectors/v1.json lets you check
your work before talking to anything real.
Keep the part after #. It is the capability, the service cannot recover it for
you, and anyone who reads it has the access you were given.
Before redeeming, fetch GET /v1/hosts/<host_id> and verify it: the record must
name your host_id, the identity key in it must hash to that host_id, and the
signature must verify under that key. Take the address and the SSH host key
from the record and pin the host key when you connect. Both redeemers do this;
docs/whitepaper.md §7.1 specifies it. Skipping the pin hands
whoever resolves the address the choice of which machine you land on.
If you want to grant access, POST /grants on the owner Unix socket. That
socket is reachable only by the enrolled account on that machine — there is no
remote endpoint that creates grants, deliberately. You can only share a machine
you are already on.
What registration is worth: you must register an identity to redeem, and it costs a proof of work. It is an abuse control, not a security boundary, and it cannot be more than that — the signer that actually decides has no network and no registry to consult. Authority comes only from the grant secret.
Stated as invariants, each one tested:
- The SSH CA private key never leaves the host.
- The host identity private key never leaves the host.
- The visiting agent's SSH private key never leaves the visiting agent.
- Compromise of the coordination service, its database, and its deployment credentials is insufficient to mint a certificate.
- A compromised service cannot substitute its own SSH public key into a redemption.
- Grants are redeemed once, expiry is enforced by the host, and the host is authoritative over its own copy of every field.
- The network-facing daemon cannot read either private key. It runs as a
separate user with
/etc/grantd, the signer's state, and the owner socket hidden from it. The daemon socket has no route that creates a grant or signs arbitrary bytes. The signer itself runs with no network at all. - The visitor accepts only a hostname, port, user, and certificate that the host signed, either directly (the registration) or through its CA (the certificate), and pins the SSH host key the host published in that same signed record. A compromised service can refuse to route a visitor; it cannot send one to a machine it controls — not even by pointing a name it resolves at a machine of its own, because the key does not match.
Invariants 1–7 protect the host from the service. Invariant 8 protects the visitor from it. The two halves of invariant 8 do different jobs: the certificate proves the visitor to the host, and the pinned host key proves the host to the visitor. For an agent, the second matters as much as the first — a shell on an attacker's box is an attacker-controlled input channel into whatever the agent does next.
The rendezvous protocol has five message types and no generic RPC frame. There is no message that carries a command, a path, or a filename, and there will not be one.
| Suite | Environment | Covers |
|---|---|---|
cd go && go test ./... |
— | canonical encoding, signer, a hostile coordination service |
cd cloudflare && npm test |
Miniflare | Worker routing, Durable Objects, cross-language vectors |
tests/e2e/run.sh |
two containers | capability URL to SSH session, driven only by curl and POSIX sh |
tests/install/run.sh |
Docker + systemd | install, sandbox, uninstall, SSH survival |
tests/install/release.sh |
Docker + systemd | install from signed artifacts; tampered and wrongly-signed releases |
tests/vm/run.sh |
Lima VM, Ubuntu LTS | reboot, unprivileged sandbox, host offline and back |
tests/remote/run.sh |
a host you supply, optionally a visitor too | a real network path between visitor and host; a session ended at its deadline and on revocation |
tests/remote/digitalocean.sh |
two throwaway droplets | the above between two machines that have never met, provisioned and destroyed automatically |
.github/workflows/ci.yml |
real amd64 VM | the installer run natively; the systemd sandbox on amd64 |
.github/workflows/droplets.yml |
two droplets, from CI | the checkout's binaries, host in one region and visitor in another |
The protocol has three independent implementations — Go, TypeScript, POSIX shell — and all three are checked against the same frozen vectors rather than against each other. Two implementations that only ever talk to each other can be wrong in the same way forever.
The last suite needs a machine with an address a stranger can route to, which no container and no Cloudflare product can provide. Workers do accept inbound TCP now, but that path runs through Spectrum, and the property under test is precisely that Cloudflare is not in the path.
DIGITALOCEAN_TOKEN=dop_v1_... tests/remote/digitalocean.shTwo droplets, about two cents, about ten minutes, destroys everything on any exit path. The host is in one region and the visitor in another, so the session under test is a stranger's SSH connection across the internet, and neither end is the machine running the script. It also holds a session open past its grant's deadline and checks the host ends it, and the process it was running, within the documented bound while a session under another grant survives.
The same suite runs from GitHub Actions as the droplets workflow, on every
push to main and on workflow_dispatch against any branch. It builds the checkout
and installs those binaries with --local-dir, so it tests unreleased code; it
needs a DIGITAL_OCEAN_TOKEN repository secret. Pass a version to install a
published release instead. Droplets are tagged with the run id and swept by
tag when the job ends, even if it was cancelled.
What can the visiting agent do once connected? Anything that account can do.
V1 has no command restrictions and no session recording. The bound is the
account you enrolled and the certificate's lifetime. It cannot mint further
grants: the owner socket admits one uid, and --owner-user must not be the
account visitors log in as — the installer refuses to finish otherwise, and
checks by trying it. If that account has sudo,
so does your visitor — that is your machine's existing policy, not something
grantd grants.
Can I self-host the coordination service? Yes. It is a Cloudflare Worker in
cloudflare/; npm run deploy puts it on your own account. The
host only needs --origin pointed at it. Since the service is not trusted, who
runs it matters less than it usually would.
What happens if the service goes down? Existing certificates keep working — SSH never depended on it. No new grants can be redeemed until it returns.
Why does a lost response burn the grant? Because the alternative was a retry path inside the transaction that makes grants single-use, and that is the one function where a mistake means two keys get access. Grants are free to mint.
Is the certificate revocable? Not as a certificate — there is no CRL and nothing to publish. What you can do is revoke the grant, which stops any further redemption and terminates every process of a session running under it within a couple of seconds. Keep TTLs short: that is still the design.
Does the deadline end an open session? Yes. sshd checks a certificate only
when it authenticates, so expiry alone would leave a running session alone —
that is OpenSSH's behaviour. grantd closes the gap with containment, not log
scraping: before any visitor code runs, a root-owned supervisor
(grant-warden) places the session's sshd process into a per-grant cgroup v2
subtree the visitor cannot leave, and at the deadline or on revocation it
empties that subtree with cgroup.kill. Because the bound is the cgroup and
not a process name, nohup, setsid, and double-forking do not escape it. The
supervisor holds no keys; it learns the deadline from the signer over a
read-only socket. Operator sessions and other grants are never in the
termination target.
What exactly does the deadline guarantee? Three different things, and only the first two are promised. Certificate expiry stops new authentication with that certificate. Execution expiry terminates every process inside the grant's containment boundary. Persistent effects — files the visitor wrote, secrets it copied, actions it took on other systems — are outside the guarantee and remain after it. grantd bounds execution; it is not a sandbox that undoes what ran.
V1: Linux hosts, OpenSSH, one enrolled non-root account per host, direct SSH reachability. No relaying, no session recording, no command restrictions, no Windows.
The protocol is frozen and the security model is tested against a deliberately hostile coordination service. It has had one internal review, whose findings are fixed in this tree, and no external security review — worth knowing before you point it at something that matters.
Start with the whitepaper — the whole security argument is
there. Then go/signer/redeem.go, the only code path
that can produce SSH access, and
go/signer/store/store.go, where single-use is
enforced.