Verified execution
Verified execution is an opt-in mode that replaces "pipe arbitrary root bash
over SSH" with "run only a signed script a trusted key vouched for". It closes
the largest exposure of the default model: that anything able to write an
SSHJoinProfile (a cluster-scoped CRD) — or a compromised controller — can run
as root on every pool host.
It uses only stock OpenSSH on the host (ssh-keygen -Y verify,
ForceCommand, authorized_keys restrictions). No agent daemon, no CA service,
no Vault. Raw mode (the default) is unchanged; migrate host by host.
The idea in one picture
flowchart LR
A["author"]:::dim --> CI["CI: ssh-keygen -Y sign<br>(key stays offline)"]:::pool
CI --> P["SSHJoinProfile<br>scripts + signatures"]:::pool
P --> C["controller<br>verifies, then relays"]:::prov
C -->|"ssh · envelope on stdin"| S["sshd ForceCommand<br>→ kpssh-shim"]:::prov
S --> V{"ssh-keygen -Y verify<br>vs allowed_signers"}:::pool
V -->|valid| R["run as root"]:::pay
V -->|invalid| X["reject · nothing runs"]:::warn
classDef pool fill:#e3efec,stroke:#1d6e5e,color:#1c2422
classDef pay fill:#f6ead9,stroke:#b35a00,color:#1c2422
classDef prov fill:#e7ecf9,stroke:#274bb0,color:#1c2422
classDef warn fill:#f6e4e2,stroke:#a83a2e,color:#1c2422
classDef dim fill:#f6f8f6,stroke:#5c6b66,color:#1c2422
The pipeline above is what happens. The reason it holds is who holds which key — and the controller, the component most exposed to the cluster, holds nothing that can author code:
flowchart LR
subgraph OFF["offline · CI or a laptop"]
SK["kpssh-signer<br><b>PRIVATE</b>"]:::pay
end
subgraph CTRL["controller · in the cluster"]
CK["SSH client key"]:::prov
TS["trustedSigners<br>(public)"]:::prov
NK["<b>no signing key</b><br>cannot author code"]:::warn
end
subgraph HOST["pool host"]
AS["allowed_signers<br>(public)"]:::pool
FC["ForceCommand → shim<br>sudo: shim only"]:::pool
end
SK -->|"signs script bytes"| TS
SK -.->|"public half, provisioned once"| AS
CK -->|"opens a session it cannot widen"| FC
FC -->|"verifies against"| AS
classDef pool fill:#e3efec,stroke:#1d6e5e,color:#1c2422
classDef pay fill:#f6ead9,stroke:#b35a00,color:#1c2422
classDef prov fill:#e7ecf9,stroke:#274bb0,color:#1c2422
classDef warn fill:#f6e4e2,stroke:#a83a2e,color:#1c2422
Both public files (trustedSigners, allowed_signers) need no secrecy — only
integrity. The one secret that matters never touches the cluster, which is why
compromising the controller does not yield the ability to sign.
Two independent checks, and the signing key touches neither:
- Controller-side (pure Go) — before it connects, the controller proves the
script is signed by one of the host's
trustedSigners. Fail-fast; it never even ships an unsigned or tampered script.execMode: Verifiedrequires a non-emptytrustedSigners(CRD-enforced), so this check cannot be disabled by accident. - Host-side (
kpssh-shim, pinned byForceCommand) — before it runs, the shim re-verifies againstallowed_signers. Because the signing key is offline, a compromised controller cannot forge a signature the shim would accept.
What this does not close
Be clear about the edges before you rely on it.
- Replay / rollback. A signature covers the script bytes and nothing else —
not a version, not an expiry. A controller that has been compromised can
re-send an older, still validly signed script (say, a
joinyou have since fixed) and the shim will run it. Signing is offline precisely so the key survives that compromise, but the flip side is that there is no online channel to revoke a signature. The mitigations are OpenSSH's own: giveallowed_signersentries avalid-beforeand re-sign on a cadence, and rotate the signer if you must invalidate history. - Phase binding. The
phase:header is not covered by the signature. This costs little in practice — a compromised controller may legitimately ask for any phase — but it does mean a signature is valid for the script it signed, not for the operation it was signed for. - Params are not signed. Only their names are constrained (
KPSSH_*, so they cannot reachBASH_ENV,LD_PRELOAD,PATHand friends). Values are attacker-controlled data in the compromised-controller case: a signed script must treat$KPSSH_*as untrusted input, quote it, and neverevalit. - The host is still the host. Anyone with root on a pool host, or who can
rewrite
/etc/kpssh/allowed_signers, owns the trust root. Verified exec constrains the controller, not the machine.
What the shim will run
The shim exposes a fixed operation vocabulary — nothing else is reachable over the pinned SSH login:
| op | signed? | mutating | notes |
|---|---|---|---|
probe |
no | no | built-in read-only facts (nproc, memory, arch, kubelet state) |
install / join / leave / uninstall |
yes | yes | require a valid signature over the exact script bytes |
Exit codes: 0 ok · 1 internal error (see stderr) · 2 signature rejected ·
3 bad param · 4 malformed envelope · 5 unknown phase · 10 the signed
script itself failed (its real exit code is in the shim's log line, so a
systemctl exiting 2 can never masquerade as "signature rejected").
Params reach the script as environment variables, and the shim accepts a name
only if it matches KPSSH_[A-Za-z0-9_]+. That is a security boundary, not
tidiness: a bare shell identifier like BASH_ENV, ENV, LD_PRELOAD, PATH
or IFS steers the interpreter rather than the script, and bash expands
$BASH_ENV — running any command substitution in it — before a non-interactive
script starts. Restricting names to the KPSSH_ contract is what keeps the
param channel from becoming an execution channel.
Scripts must be template-free
Verified profiles are signed as bytes: what runs must equal what was signed.
Go text/template actions ({{ … }}) render per node, so they are rejected
in verified mode. Move every per-node value to the KPSSH_* environment (the
provider already exports all of it) — the shim applies validated params, then
runs the pristine signed script. A signed script that does eval, curl | bash
or unquoted expansion is still a bug the signature does not fix; keep the
KPSSH_* contract and lint your profiles.
Quickstart
End to end on one host. Assumes ssh-keygen ≥ 8.1 locally and on the host (see
Requirements).
1. Create a signer (keep the private half offline)
ssh-keygen -t ed25519 -f kpssh-signer -N '' -C kpssh
# kpssh-signer → CI secret / 1Password. NEVER on the controller or a host.
# kpssh-signer.pub → public; goes into allowed_signers (step 2) + the SSHHost.
2. Provision the host for verified mode
Do this from your image / Ansible / cloud-init — the same automation that already prepares the host. As root on the host:
# Fetch the shim from the release matching your controller image, and check it
# before you install it: this file runs as root on every host in the pool, so
# fetching it unverified would hand away exactly what the shim exists to
# protect. Pin VERSION — "latest" is a moving target for a root-privileged file.
VERSION=v1.0.4 # x-release-please-version
base=https://github.com/dklesev/karpenter-provider-ssh/releases/download/${VERSION}
curl -fsSLO "${base}/kpssh-shim"
curl -fsSLO "${base}/kpssh-shim.sha256"
sha256sum -c kpssh-shim.sha256 # macOS: shasum -a 256 -c
install -D -m 0755 kpssh-shim /opt/kpssh/kpssh-shim
The checksum only proves the two files came from the same place. To prove the shim came from this repo's release workflow and not from whoever last had write access to it, verify the build provenance instead — the signature chains to the workflow's OIDC identity, which a release-asset overwrite cannot forge:
gh attestation verify kpssh-shim --repo dklesev/karpenter-provider-ssh
# the trust root — public key material, no secrecy needed
install -d /etc/kpssh
printf 'kpssh namespaces="kpssh" %s\n' "$(cut -d' ' -f1-2 kpssh-signer.pub)" \
> /etc/kpssh/allowed_signers
# a dedicated pool user, sudo-scoped to exactly the shim (a real allow-list)
useradd -m kpssh
printf 'kpssh ALL=(root) NOPASSWD: /opt/kpssh/kpssh-shim\n' > /etc/sudoers.d/kpssh
chmod 440 /etc/sudoers.d/kpssh
# pin the controller's key to the shim; no pty, no forwarding, source-locked
install -d -o kpssh -g kpssh -m 700 /home/kpssh/.ssh
printf 'restrict,command="/opt/kpssh/kpssh-shim",from="%s" %s\n' \
"10.0.0.0/8" "$(cat controller_key.pub)" \
> /home/kpssh/.ssh/authorized_keys
chown kpssh:kpssh /home/kpssh/.ssh/authorized_keys
chmod 600 /home/kpssh/.ssh/authorized_keys
# pin the login to the shim; no pty, no forwarding
cat > /etc/ssh/sshd_config.d/50-kpssh.conf <<'EOF'
Match User kpssh
ForceCommand /opt/kpssh/kpssh-shim
PermitTTY no
AllowTcpForwarding no
EOF
# validate BEFORE reloading: on RHEL-family hosts sshd reloads without a
# pre-check and dies on a config error — on a remote host that is a lockout.
# Keep a second session open the first time regardless.
sshd -t || { rm -f /etc/ssh/sshd_config.d/50-kpssh.conf; echo "config rejected"; exit 1; }
systemctl reload sshd
The client must not be able to set the shim's environment: the shim reads its
trust root (KPSSH_ALLOWED_SIGNERS) and its namespace/principal from the
environment so that provisioning can place them. sudo scrubs those on the
escalating path above, but a host whose SSHHost sets user: root never
escalates — let a client set the environment there and it chooses its own
allowed_signers, after which the signature check happily validates a key the
attacker holds. sshd's global defaults already refuse client environment
(no AcceptEnv, PermitUserEnvironment no; neither can be narrowed inside a
Match block) — just confirm nothing in your config re-enabled them:
sshd -T -C user=kpssh,host=h,addr=127.0.0.1 | grep -Ei 'acceptenv|permituserenv'
# expect: permituserenvironment no — and no acceptenv lines
from= is defense-in-depth — pin it to the pod/node subnet the host sees (or the
NAT egress IP), and enforce the same at your security group / firewall. The
force-command and the signature are what actually gate execution.
3. Sign the profile's scripts (offline / CI)
Keep phase scripts as files and sign each in the kpssh namespace:
ssh-keygen -Y sign -f kpssh-signer -n kpssh join.sh # → join.sh.sig
# …repeat for install.sh, leave.sh, uninstall.sh
The helper
hack/sign-profile.sh
signs a directory of phase scripts and prints the ready-to-paste signatures: block:
hack/sign-profile.sh kpssh-signer ./scripts/
4. Ship the signed profile
apiVersion: karpenter.dklesev.github.io/v1beta1
kind: SSHJoinProfile
metadata: { name: tls-bootstrap }
spec:
version: "1"
scripts:
install: |
#!/usr/bin/env bash
set -euo pipefail
# … template-free; reads ${KPSSH_*} …
join: |
#!/usr/bin/env bash
# …
leave: |
#!/usr/bin/env bash
# …
signatures: # from step 3
install: |
-----BEGIN SSH SIGNATURE-----
…
-----END SSH SIGNATURE-----
join: |
-----BEGIN SSH SIGNATURE-----
…
-----END SSH SIGNATURE-----
leave: |
-----BEGIN SSH SIGNATURE-----
…
-----END SSH SIGNATURE-----
5. Flip the host to Verified
apiVersion: karpenter.dklesev.github.io/v1beta1
kind: SSHHost
metadata: { name: host-1 }
spec:
address: 10.0.0.11
user: kpssh
sshKeySecretRef: { name: pool-ssh-key }
capacity: { cpu: "8", memory: 16Gi }
execMode: Verified
trustedSigners: # required in Verified mode; the .pub from step 1
- "ssh-ed25519 AAAA… kpssh"
# shimCommand: /opt/kpssh/kpssh-shim # override if installed elsewhere
Set execMode: Verified only after steps 2–4 for that host. Raw and Verified
hosts coexist in one pool.
6. Prove it
A scale-up that lands on host-1 now runs the signed join through the shim.
To confirm the gate works, ship a profile edit without re-signing: the
controller refuses before connecting (controller-side signature check), and had
it reached the host, the shim would reject it (signature rejected, exit 2). The
NodeClaim surfaces the rejection as an event; the host is released healthy.
Operations
The only persistent state on a host is one public file (allowed_signers) and
one config line (ForceCommand). Everything that changes routinely rides the
wire, signed.
| change | what to do | touches the node? |
|---|---|---|
| update a script | re-sign, ship the new profile; next join picks it up | no |
| rotate the signer | mark the allowed_signers entry cert-authority and issue signer certificates; rotate leaves offline |
no (with a CA) / yes otherwise |
| re-provision | your image / config-management actor | yes — never the controller |
The controller holds no key that produces a session broader than the shim, and no key that signs code the shim would accept: it cannot run code the signer never vouched for. It can still choose which signed code runs, and when — see What this does not close.
Troubleshooting
A signing or provisioning mistake never marks the host Unhealthy — the host
was not touched, so it returns to Available and the error surfaces on the
NodeClaim.
| symptom | cause | fix |
|---|---|---|
join fails, controller-side signature check |
profile signature missing/invalid, or signer not in trustedSigners |
re-sign; add the .pub to SSHHost.spec.trustedSigners |
join fails, shim exit 2 signature rejected |
script edited after signing — or a provisioning problem: the shim logs ssh-keygen's own error (verify: …) alongside, e.g. a missing/unreadable allowed_signers |
re-sign, or fix /etc/kpssh/allowed_signers per the logged reason |
shim exit 4 malformed |
host is force-command-locked but got a non-envelope (mode mismatch) | ensure execMode: Verified matches the host's actual sshd |
shim exit 10, script exited N in the log |
the signed script itself failed with exit N |
debug the script; the signature gate is fine |
verified exec requires template-free scripts |
profile uses {{ … }} |
move variability to KPSSH_* params |
sudo: a password is required |
pool user lacks the NOPASSWD shim rule | add /etc/sudoers.d/kpssh (step 2) |
Requirements
ssh-keygen -Y sign/verify need OpenSSH ≥ 8.1 on the signer and host;
cert-authority in allowed_signers needs ≥ 8.9. EKS AL2023 and Ubuntu 22.04+
qualify. The controller needs no ssh-keygen (verification is pure Go).
See also: Security · Join profiles.