PasskeyBase

SSH Auth

Log into a CLI with the SSH key you already have. No browser round trip, no device code to type in -- works over an agent-forwarded SSH session into a remote box, and in headless CI where there is no browser at all.


What it is, and why

Passkeys cover the browser. SSH auth is the terminal equivalent: your CLI tool proves possession of an SSH key by signing a server-issued challenge, and exchanges that signature for an OAuth access token. The private key never leaves ssh-agent -- the same agent that already handles your Git pushes and remote logins, including hardware keys (sk-*), 1Password's SSH agent, and agent forwarding into a devbox over SSH.

Three things fall out of that design:

  • No browser round trip. A CLI tool can complete a login entirely from stdin/stdout.
  • Works over forwarded agents. SSH into a remote devbox with ssh -A, and your local key signs the challenge from there too.
  • Works in headless CI. Load a deploy key into an ephemeral agent and authenticate with no interactive prompt.

All of this runs through the same HTTP stack as every other grant -- rate limiting, tenancy, and audit logging are not reinvented for SSH. The signature verification is modeled as a custom OAuth grant, not a side channel.


The challenge → sign → token flow

Three steps, all against your tenant's normal OAuth endpoints:

  1. POST /oauth/ssh/challenge -- the client sends its client_id (and optionalscope); the server returns a single-usechallenge_id and a random nonce good for 60 seconds. This endpoint never discloses enrolled-key information -- it's not an oracle for anything.
  2. Sign. The client builds the canonical payload below and signs it viassh-agent, producing anSSHSIG (thessh-keygen -Y sign format) in the[email protected] namespace. The namespace gives domain separation -- a signature made for us can never be replayed as an SSH login or a Git commit signature, and vice versa.
  3. POST /oauth/token withgrant_type=urn:passkeybase:params:oauth:grant-type:ssh -- the server reconstructs the same canonical payload server-side (it never trusts a client-supplied payload), verifies the signature, resolves the signing key to an enrolled user, and mints an access token.

The canonical payload is fixed, newline-terminated text -- nothing about it is negotiable:

Signed payload
passkeybase-ssh-v1
nonce=<nonce>
aud=https://acme.auth.ing
client_id=acme-cli
scope=deploy

Algorithm policy: ssh-ed25519,[email protected],ecdsa-sha2-*, andrsa-sha2-256/512 are accepted. Legacy ssh-rsa (SHA-1) andssh-dss are rejected.


Using @passkeybase/cli

@passkeybase/cli is a small TypeScript library your own CLI tool embeds -- it is not a standalone command. It drives the whole flow above: it talks to SSH_AUTH_SOCKover the raw agent wire protocol first, falls back to shelling out tossh-keygen -Y sign -U for identities the direct path can't drive (hardware keys the agent handles specially), and falls back again to a default on-disk key (~/.ssh/id_ed25519,id_ecdsa,id_rsa, in that order) if no agent is reachable.

Terminal
npm install @passkeybase/cli

Call login() with your tenant's origin and the client ID from the dashboard:

cli.js
import { login } from '@passkeybase/cli';

const tokens = await login({
  baseUrl: 'https://acme.auth.ing',
  clientId: 'acme-cli',
  scope: 'deploy',
});

console.log(tokens.access_token);

Pass an explicit sshKeyPath to skip the agent and sign directly with a given private key file, or akeySelector to restrict which agent identities are tried when the agent has several keys loaded.


Zero-install: stock OpenSSH + curl

There's no proprietary magic in the protocol -- the whole flow can be driven with tools already on your machine. This is the same flow the CLI library automates, spelled out withcurl andssh-keygen for anyone who wants to see exactly what's happening on the wire, or for a shell script that shouldn't take a Node dependency:

Terminal
# 1. get a challenge
resp=$(curl -s https://acme.auth.ing/oauth/ssh/challenge \
  -d client_id=acme-cli)

# 2. build the canonical payload and sign it via the agent
sig=$(printf '%s' "$payload" | \
  ssh-keygen -Y sign -n [email protected] -U -f ~/.ssh/id_ed25519.pub)

# 3. redeem it
curl -s https://acme.auth.ing/oauth/token \
  -d grant_type=urn:passkeybase:params:oauth:grant-type:ssh \
  -d client_id=acme-cli -d challenge_id=... --data-urlencode signature="$sig"

Requires OpenSSH ≥ 8.0 (2019). -U tellsssh-keygen to sign via the running agent, selecting the key by its public half. The server accepts the raw binary SSHSIG blob (base64url) or the PEM-armored -----BEGIN SSH SIGNATURE----- text exactly as ssh-keygen -Y sign emits it, so there's no dearmoring step required in shell.


Enrolling keys

A key has to be enrolled before it can authenticate. Enrolled keys show up in the same credential list as passkeys.

From the dashboard

The default path: sign in, add a public key from your account page (or, for Express-native tenants, via the Management API), and it's immediately usable.

GitHub import

During enrollment, type a GitHub handle instead of pasting a key. PasskeyBase fetcheshttps://github.com/<user>.keysand completes enrollment once you prove possession of one of the listed keys via a normal challenge. GitHub enforces key uniqueness across its own accounts, so proving possession against a handle's key list binds the key to that identity. After import there's no ongoing dependency on GitHub -- the key is a direct enrollment from that point on.


Troubleshooting

@passkeybase/cli throws typed errors so your CLI can print something actionable instead of a stack trace:

cli.js
import {
  login,
  NoAgentError,
  NoUsableKeyError,
  InvalidGrantError,
} from '@passkeybase/cli';

try {
  const tokens = await login({
    baseUrl: 'https://acme.auth.ing',
    clientId: 'acme-cli',
  });
} catch (err) {
  if (err instanceof NoAgentError) {
    console.error('No ssh-agent reachable and no ~/.ssh key found. Run "eval $(ssh-agent)" and "ssh-add".');
  } else if (err instanceof NoUsableKeyError) {
    console.error('ssh-agent has no identity enrolled with PasskeyBase.');
  } else if (err instanceof InvalidGrantError) {
    console.error('Challenge expired, already used, or key not enrolled for this client.');
  } else {
    throw err;
  }
}

NoAgentError -- no agent

SSH_AUTH_SOCK is unset or unreachable, and no default key was found under ~/.ssh. Start an agent (eval $(ssh-agent)) and load a key (ssh-add), or passsshKeyPath explicitly.

NoUsableKeyError -- no usable key

The agent is reachable, but none of its loaded identities (after anykeySelector filtering) could produce a signature -- typically an empty agent, or akeySelector that matched nothing. Check ssh-add -l.

InvalidGrantError -- invalid grant

The server rejected the redemption: the challenge expired (60s TTL), was already used, or the signing key isn't enrolled for this client. This error is deliberately uniform -- it never distinguishes "wrong key" from "unknown key" from "expired challenge", so the token endpoint can't be used to enumerate enrolled keys.

Next steps