Push your Secretive Secure Enclave SSH key onto Proxmox VMs and LXC containers, and get a working ssh <name> — authenticated with Touch ID — without ever handling a private key file.
proxkey is a single Go binary with two modes:
- CLI —
proxkey list,proxkey keyify web,proxkey watch, … - Chrome native-messaging host — backs a browser extension so you can keyify a VM straight from the Proxmox web UI.
Your private key never leaves the Secure Enclave. Your Proxmox API token secret never touches disk in plaintext — it lives in the macOS Keychain.
Full docs live in docs/:
| Document | Read it when |
|---|---|
| Setup | First install — Secretive, API token, TLS pinning, node bootstrap |
| API token | Creating a least-privilege token, and why each privilege |
| CLI reference | Day-to-day commands |
| Chrome extension | Installing and using the browser front-end |
| Architecture | How it fits together; native-messaging protocol |
| Troubleshooting | Something is wrong |
| Security | Threat model, hardening checklist, reporting a vulnerability |
- Your public key is read from the Secretive agent (
ssh-add -Lagainst its socket). - That key is pushed to the guest by the most reliable route available for its type and state (cloud-init, QEMU guest agent, or
pct execover SSH). - A marker-delimited
Hostblock is written to~/.ssh/config.d/proxkey.conf, pinningIdentityAgentto the Secretive socket.
# proxkey:begin home/101
Host web
HostName 10.0.0.42
User root
IdentityAgent "~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh"
# proxkey:end home/101
Then ssh web prompts for Touch ID. That's the whole product.
- macOS (Apple silicon or Intel)
- Go 1.24+ and the Xcode command line tools (
xcode-select --install) to build — the Keychain integration uses cgo - Secretive installed, with a Secure Enclave key created and the SecretAgent running
- A Proxmox VE cluster reachable over HTTPS, and an API token
- Proxmox VE 8.0+ to key running VMs (the
VM.GuestAgent.*privileges landed in 8.0); stopped guests work on 7.x
Verify Secretive is live:
SSH_AUTH_SOCK=~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh ssh-add -LYou should see one or more public key lines. If you see Error connecting to agent, open Secretive and make sure the agent is running.
git clone <this repo> proxkey && cd proxkey
./scripts/install.sh <CHROME_EXTENSION_ID>The script:
- builds to
/usr/local/bin/proxkey(falling back to~/binwith a PATH note), - writes the native-messaging manifest to
~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.raven.proxkey.json, - offers to run
proxkey setup.
It is safe to re-run — pass a new extension ID any time, or omit the argument to reuse the previously registered one. Restart Chrome after registering the host.
CLI-only install (no extension):
go build -o /usr/local/bin/proxkey ./cmd/proxkey
proxkey setupLet proxkey write the recipe for you:
proxkey tokenThat prints copy-pasteable pveum commands creating a dedicated user, two narrow
roles, and a token scoped to exactly what proxkey calls — plus the ACL grants for
both the user and the token, which is the step that trips everyone up.
Every one of these maps to a specific API call. Nothing here is speculative.
| Privilege | Path | Why |
|---|---|---|
Sys.Audit |
/ |
read /cluster/tasks and /cluster/config/nodes |
VM.Audit |
/vms |
list guests; read container network interfaces |
VM.Config.Cloudinit |
/vms |
set sshkeys on stopped VMs and templates |
VM.Config.Options |
/vms |
set ssh-public-keys on stopped containers |
VM.GuestAgent.Audit |
/vms |
discover a running VM's IP |
VM.GuestAgent.FileRead |
/vms |
read authorized_keys (idempotency) |
VM.GuestAgent.FileWrite |
/vms |
append the key on a running VM |
Notably not required: VM.Allocate, VM.PowerMgmt, VM.Config.Disk,
VM.Config.Network, Sys.Modify, Datastore.*. proxkey cannot create, destroy,
start, stop, or reconfigure anything.
VM.GuestAgent.Unrestricted is deliberately excluded — it permits arbitrary
command execution inside guests, and proxkey only wants it to mkdir /root/.ssh
on guests that lack it. Add it with proxkey token --unrestricted if you need it.
Privilege separation is the silent failure. With it on (the default), a token's rights are the intersection of its own ACL and its user's. Grant only one side and Proxmox returns an empty list rather than a permission error, so
proxkey listprints "No VMs or containers found" on a full cluster.proxkey doctordetects this.
VM.GuestAgent.*requires Proxmox VE 8.0+. On 7.x the guest-agent endpoints are root-only and cannot be delegated.
Full explanation, pool scoping, rotation and revocation: docs/token.md.
proxkey setupInteractive. For each endpoint it asks for:
- Name — a short local label (e.g.
home); used in ssh config markers - API URL — e.g.
https://pve1.local:8006 - Token ID —
user@realm!tokenname - Token secret — stored in the macOS Keychain (service
proxkey, account = endpoint name), never in a config file - SSH user — defaults to
root
Before asking for anything secret, setup inspects the server's TLS certificate.
Proxmox ships a self-signed one, so it shows you the fingerprint alongside the
command that prints the same value on the node:
SHA-256: A4:1F:9C:22:8E:...:07:B3
Verify this matches the server before pinning. On the Proxmox node:
openssl x509 -noout -sha256 -fingerprint -in /etc/pve/local/pve-ssl.pem
Pin this certificate? [Y/n]:
Run that openssl command and compare. That comparison is the whole point — it
is what proves you are talking to your server. Once pinned, any other certificate
is refused, which matters because the API token rides in a plain Authorization
header on every request.
Certificates from a real CA are detected and need no pin. Re-pin after a renewal
with proxkey trust <endpoint>.
Then confirm everything works:
proxkey doctorIt reports each endpoint's TLS mode, checks every privilege the token needs, and verifies the Secretive key is reachable.
It also ensures ~/.ssh/config begins with:
Include config.d/proxkey.conf
The Include must be at the top, because ssh applies the first matching value for each option. proxkey puts it there and never duplicates it.
Files and permissions:
| Path | Mode | Contents |
|---|---|---|
~/.config/proxkey/config.json |
0600 | endpoints (no secrets) |
~/.ssh/config.d/ |
0700 | |
~/.ssh/config.d/proxkey.conf |
0600 | managed Host blocks |
Keychain proxkey / <endpoint> |
— | API token secret |
proxkey list
proxkey list --endpoint homeENDPOINT VMID NAME NODE TYPE STATUS KEYIFIED
home 100 web pve1 qemu running yes
home 101 db pve1 lxc running
home 900 tpl-deb pve1 qemu stopped
KEYIFIED shows yes when a managed block exists in proxkey.conf.
proxkey keyify web # by name
proxkey keyify 101 # by vmid
proxkey keyify db --user admin # override the ssh user
proxkey keyify db --host 10.0.0.9 # skip IP discovery
proxkey keyify web --endpoint home # disambiguate across clusters--endpoint is optional when the name/vmid is unambiguous — with several endpoints configured, proxkey searches all of them and errors only if the name matches more than one.
What happens per guest type:
| Guest | State | Mechanism |
|---|---|---|
| QEMU | stopped / template | PUT .../qemu/{vmid}/config with sshkeys (cloud-init) |
| QEMU | running | guest agent: file-read → append → file-write on /root/.ssh/authorized_keys |
| QEMU | running, no agent | falls back to cloud-init sshkeys + prints applies next boot |
| LXC | running | ssh root@<node> pct exec <vmid> -- … (see bootstrap below) |
| LXC | stopped | PUT .../lxc/{vmid}/config with ssh-public-keys |
IP discovery for the Host block uses the QEMU guest agent (network-get-interfaces) or GET /nodes/{node}/lxc/{vmid}/interfaces, skipping loopback and taking the first IPv4. If neither works, proxkey tells you to pass --host.
Keyify is idempotent — running it twice will not duplicate a key in authorized_keys nor a block in proxkey.conf. Re-running with a new IP rewrites the existing block in place.
proxkey watch # next guest created, then stop (15 min limit)
proxkey watch --vmid 108 # only vmid 108
proxkey watch --limit 5 --ttl 1h
proxkey watch --once # single poll cycle, then exitPolls /cluster/tasks for newly finished qmcreate / vzcreate tasks (tracking seen UPIDs) and auto-keyifies the new guest.
Watch is bounded by default, deliberately. It identifies its target as "a guest that appeared while I was watching", which without limits also matches a VM a colleague creates or one a backup restore produces in the same window — and it would install your key into root on it. So a bare proxkey watch keys exactly one guest and retires after 15 minutes. Guests outside the scope are reported as skipped and left untouched. Widen with --limit / --ttl when you mean to.
The Chrome extension reads the VM ID out of the create wizard and passes it through, so a wizard-armed watch is pinned to that single guest.
Continuous mode primes on startup — existing create tasks are marked seen so you don't get a burst of keyify attempts for guests you already have. --once has no prior state to diff against, so it considers create tasks that finished within the last 10 minutes.
A freshly created VM is usually still stopped and has no IP yet, so watch will commonly report that it set cloud-init keys but could not determine an IP. That is expected; re-run keyify once the guest is up.
proxkey revoke webRemoves the Host block, and makes a best-effort attempt to strip the key from the guest's authorized_keys (guest agent for QEMU, pct exec for LXC). If the guest is off, the block is still removed and you get a warning.
Replaced your Mac? The Secure Enclave key is gone with it — a new Secretive key has a different public half. After proxkey setup on the new machine, copy your old ~/.ssh/config.d/proxkey.conf across and run:
proxkey rekey-allThis walks every managed block and re-runs keyify with the current Secretive public key, reusing each block's recorded HostName and User so it does not depend on live IP discovery. Failures are reported per host and the command exits non-zero if any failed.
Note the bootstrap catch: for running LXC containers this needs SSH access to the PVE node, which needs your new key already on the node. Do that first (see below).
Proxmox has no API to execute commands inside a running container — there is no LXC equivalent of the QEMU guest agent. The only reliable route is to SSH to the Proxmox node itself and use pct exec.
So for running LXC containers, your Secretive public key must already be in /root/.ssh/authorized_keys on the PVE node. One-time, per node:
# Print your Secretive public key
SSH_AUTH_SOCK=~/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh ssh-add -L
# Install it on the node (password auth, once)
ssh root@pve1 'mkdir -p /root/.ssh && chmod 700 /root/.ssh && cat >> /root/.ssh/authorized_keys' <<< '<paste the key line>'If this is missing, proxkey keyify on a running container fails with an explicit message telling you exactly this.
Node address resolution: proxkey resolves the Proxmox node name via DNS, and falls back to the host from the endpoint's API URL (correct for single-node setups). Stopped containers use the API and need no SSH access at all.
The extension lives in extension/ and talks to this binary over Chrome native messaging.
./scripts/install.sh <EXT_ID>(registers the host manifest)chrome://extensions→ enable Developer mode → Load unpacked → selectextension/- Copy the extension ID shown on the card
- Re-run
./scripts/install.sh <EXT_ID>with that ID if you guessed wrong the first time - Restart Chrome
Frames are a 4-byte little-endian uint32 length prefix followed by UTF-8 JSON, in both directions over stdin/stdout. The host writes diagnostics to stderr only — stdout carries protocol frames exclusively.
Request:
{"id": 1, "cmd": "list", "endpoint": "home"}Response:
{"id": 1, "ok": true, "data": { }}
{"id": 1, "ok": false, "error": "message"}| Command | Params | data |
|---|---|---|
endpoints |
— | {"endpoints":[{"name","api_url"}]} |
list |
endpoint |
{"boxes":[{"vmid","name","node","type","status","keyified"}]} |
keyify |
endpoint, vmid, user?, host? |
{"name","host","alias"} |
revoke |
endpoint, vmid |
{} |
status |
endpoint, vmid |
{"keyified":bool,"alias"?} |
watch-start |
endpoint, vmid? |
{"scope":"…"} then pushes events |
watch-stop |
endpoint |
{} |
While watching (over a long-lived chrome.runtime.connectNative port), the host also pushes unsolicited frames with no id:
{"event":"created", "endpoint":"home", "vmid":105, "type":"qemu"}
{"event":"keyified", "endpoint":"home", "vmid":105, "name":"web", "alias":"web", "host":"10.0.0.42"}
{"event":"skipped", "endpoint":"home", "vmid":106, "message":"not the guest this watch is scoped to (vmid 105)"}
{"event":"expired", "endpoint":"home", "message":"watch on home expired after 15m0s …"}
{"event":"error", "message":"…"}endpoint may be omitted when exactly one endpoint is configured. list with no endpoint returns guests from every endpoint.
Secretive SecretAgent socket not found — Secretive is not installed or its agent is not running. Open the app; make sure a Secure Enclave key exists.
Anything at all — run proxkey doctor first. It checks TLS mode, every token privilege, and the Secretive key, and names the specific fix.
no API token secret in Keychain for endpoint "home" — re-run proxkey setup. Do not add it with security add-generic-password: an item created that way is readable by any program that invokes /usr/bin/security, whereas one created by proxkey is bound to proxkey.
macOS prompts for Keychain access after every rebuild — expected. The item's access control list names the binary, and rebuilding changes its code identity.
Parameter verification failed: sshkeys — the key was not encoded as Proxmox expects. proxkey handles this (see below); if you see it, the endpoint may be running an unusual PVE version — please report it.
does not match the pinned fingerprint — the server presented a different certificate than the one you pinned. If you renewed it, verify the new fingerprint on the node and run proxkey trust <endpoint>. If you did not, stop: the connection may be intercepted. See troubleshooting.
host key is not trusted — proxkey will not silently accept an unknown SSH host key for a PVE node. Verify it with ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the node, then ssh-keyscan it into known_hosts, or set "trust_new_hosts": true on the endpoint.
ssh web does not use Touch ID — confirm ~/.ssh/config starts with Include config.d/proxkey.conf, then check ssh -v web for which IdentityAgent was applied. An earlier Host * block elsewhere in your config can win, since ssh takes the first match per option.
Native host not found in Chrome — restart Chrome after running install.sh, and confirm the ID in allowed_origins matches the one on chrome://extensions.
Proxmox stores the cloud-init sshkeys option as a URL-encoded string. Because the value is submitted inside an application/x-www-form-urlencoded body, the API layer decodes it once before storing it. The value therefore has to be encoded twice on the wire for a single-encoded value to land in the VM config.
There is a second trap. Go's url.QueryEscape encodes spaces as +, but the Proxmox validator requires RFC 3986 style %20 and rejects the + form with invalid urlencoded string. proxkey encodes then rewrites + to %20 — safe, because any literal + inside the base64 key body has already become %2B:
func EncodeSSHKeys(pubkey string) string {
s := url.QueryEscape(strings.TrimSpace(pubkey))
return strings.ReplaceAll(s, "+", "%20")
}url.Values.Encode() then applies the outer layer. This is covered by tests that assert the exact value a server receives decodes back to the original key.
LXC's ssh-public-keys is an ordinary parameter and takes the key verbatim — no double encoding.
go test ./...
go vet ./...
go build ./cmd/proxkeyLayout:
cmd/proxkey/ CLI entrypoint and subcommands
internal/config/ ~/.config/proxkey/config.json
internal/keychain/ macOS Keychain via Security.framework (cgo)
internal/secretive/ Secretive socket + ssh-add -L
internal/proxmox/ Proxmox VE API client + TLS certificate pinning
internal/sshcfg/ marker-delimited ssh config blocks
internal/app/ shared keyify/revoke/list/watch logic
internal/native/ Chrome native-messaging framing + host
Zero third-party dependencies — the Go standard library plus the macOS Security
and CoreFoundation frameworks. cgo is required (xcode-select --install).
Tests never contact a real Proxmox server — the client is exercised against httptest. go test -short ./... skips the one test that touches the real login Keychain.
See CONTRIBUTING.md.
proxkey holds a Proxmox API token and installs SSH keys into root accounts. The threat model — what it protects, what it explicitly does not, and a hardening checklist — is in SECURITY.md.
Report vulnerabilities through GitHub's private vulnerability reporting rather than a public issue.
Apache License 2.0. See NOTICE.