Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

proxkey

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:

  • CLIproxkey 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.

Documentation

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

How it works

  1. Your public key is read from the Secretive agent (ssh-add -L against its socket).
  2. 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 exec over SSH).
  3. A marker-delimited Host block is written to ~/.ssh/config.d/proxkey.conf, pinning IdentityAgent to 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.


Requirements

  • 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 -L

You should see one or more public key lines. If you see Error connecting to agent, open Secretive and make sure the agent is running.


Install

git clone <this repo> proxkey && cd proxkey
./scripts/install.sh <CHROME_EXTENSION_ID>

The script:

  • builds to /usr/local/bin/proxkey (falling back to ~/bin with 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 setup

Creating a Proxmox API token

Let proxkey write the recipe for you:

proxkey token

That 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.

Required privileges

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 list prints "No VMs or containers found" on a full cluster. proxkey doctor detects 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.


Setup

proxkey setup

Interactive. 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 IDuser@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 doctor

It 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

Usage

List guests

proxkey list
proxkey list --endpoint home
ENDPOINT  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.

Keyify

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.

Watch for new VMs

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 exit

Polls /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.

Revoke

proxkey revoke web

Removes 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.

Disaster recovery: rekey-all

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-all

This 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).


LXC bootstrap requirement

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.


Chrome extension

The extension lives in extension/ and talks to this binary over Chrome native messaging.

  1. ./scripts/install.sh <EXT_ID> (registers the host manifest)
  2. chrome://extensions → enable Developer modeLoad unpacked → select extension/
  3. Copy the extension ID shown on the card
  4. Re-run ./scripts/install.sh <EXT_ID> with that ID if you guessed wrong the first time
  5. Restart Chrome

Native messaging protocol

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.


Troubleshooting

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.


Implementation note: the sshkeys double-encoding quirk

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.


Development

go test ./...
go vet ./...
go build ./cmd/proxkey

Layout:

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.


Security

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.


License

Apache License 2.0. See NOTICE.

About

Proxmox SSH Key manager using secretive

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages