Zssh - SSH Identity Management Through OpenZiti

Hi all,

we've been using zssh for zero-trust SSH and really like how it works. One thing that bugs us is the disconnect between Ziti identity management and SSH authentication. Right now, Ziti handles the network layer (who can reach the SSH service), but SSH authentication is completely separate; users still need to manage SSH keys, authorized_keys files, and so on. If you revoke someone's Ziti access, their SSH key might still be sitting in authorized_keys on the server.

What we want:
If Ziti says a user has access to the SSH service, that should be the only thing needed. No separate SSH key distribution, no per-user authorized_keys maintenance, and immediate revocation when Ziti access is removed.
We've been thinking through a few approaches and wanted to get input from the community.
zssh currently works in two phases:

  1. Authenticate to Ziti, dial the SSH service through the overlay
  2. Authenticate to sshd with a completely separate SSH key

These two credential sets are independent. Adding or removing a user means touching both Ziti policies and SSH authorized_keys on every server. We'd like phase 2 to just follow from phase 1.
In my opinion there are 3 approaches to that:

Approach A: Controller-Signed SSH Certificates

The Ziti controller already has a CA and signs x509 certificates for every identity. The same key could sign SSH-format certificates. If the controller exposed an endpoint for SSH certificate signing (same CA key, different serialization), the flow would be:

  • zssh authenticates to Ziti
  • zssh requests a short-lived SSH certificate from the controller
  • zssh dials the SSH service and authenticates with the SSH certificate
  • sshd validates against the controller's CA public key via TrustedUserCAKeys

Revocation is immediate and the controller refuses to sign if the identity is revoked. Server setup is one line in sshd_config, not per-user.

This would require a new controller endpoint, which is why we're bringing it up here. This one is my favorite, and I believe also what cloudflare does: SSH with Access for Infrastructure ยท Cloudflare One docs

Approach B: Custom Go SSH Server as Ziti Host

Instead of using ziti-edge-tunnel + sshd, replace both with a single Go binary that's a Ziti host and SSH server (using x/crypto/ssh). It accepts Ziti connections via the SDK's Listen API, reads the caller's x509 certificate directly from the mTLS handshake, and makes SSH authorization decisions based on that.
No format conversion needed, the server has the x509 cert, trusts the Ziti CA, and authorizes accordingly. Revocation works because Ziti won't let a revoked identity dial the service in the first place, and even if the connection somehow arrived, the cert validation would fail.
The downside is replacing sshd with a custom implementation. That's a meaningful tradeoff in terms of audit surface and feature completeness.

Approach C: Change ziti-edge-tunnel to bridge Identity to sshd

Keep sshd, but extend ziti-edge-tunnel for SSH services. This ziti-edge-tunnel:

  • Accepts Ziti connections via the SDK's Listen API
  • Extracts the connecting identity's x509 cert and public key
  • Converts the public key to SSH format and writes it to a per-connection authorized keys source
  • Proxies the connection to sshd, which uses AuthorizedKeysCommand to read from that source

The identity context and the information that a client connects to an ssh server should be available because the connection gets terminated locally. sshd stays standard, and revocation works because the Ziti host only proxies connections from identities that pass Ziti's authentication and authorization.

More moving parts than Approach B, but keeps OpenSSH in the picture.

Has anyone solved this or something similar?
For Approach A: is there appetite for an SSH cert signing endpoint in the controller? It's should be the same CA key that already signs x509 certs, just a different output format.

For Approaches B/C: are there other ways to get the connecting identity's certificate information through the SDK that we might be missing?

Any thoughts appreciated, I'd be open to provide testing & code to a PR :slight_smile:

Best regards
Dominik

Hi @dmuensterer

You've got really good timing! This is exactly the problem we've been working on.
We just open-sourced ziti-ssh (https://github.com/netfoundry/ziti-ssh), which implements Approach A & a bit more.

How it works

Four binaries:

  • ziti-ssh-ca โ€” a CA service that runs as a ziti service (not a new controller endpoint). Authorized identities connect to it, send their SSH public key, and get back a short-lived signed certificate. It uses the controller's own intermediate CA private key, so there's a single trust root for both ziti identity certificates and SSH certificates. sshd needs one line: TrustedUserCAKeys /etc/ssh/ziti_ca.pub.
  • ziti-ssh-host โ€” enrolls the identity & runs on the host, writes that TrustedUserCAKeys line, and proxies Ziti connections to local sshd. Port 22 stays firewalled externally.
  • ziti-ssh & ziti-scp โ€” client tools that fetch a cert from the CA (cached, auto-refreshed before expiry) and opens an SSH or file copy session over ziti.

Why a separate service rather than a controller endpoint

We deliberately avoided modifying the controller(for now). Running as a standalone ziti service means no upstream PRs required โ€” you deploy it alongside your existing network. The CA service is itself protected by ziti service policies, so only identities authorized to dial ssh-ca can request certificates. A native controller endpoint could be in the future and something we'd be open to exploring if this is widely adopted.

Revocation

A revoked identity can't dial ssh-ca, so it can't get new certificates. More importantly, it also can't dial the SSH service itself โ€” so even an already-issued certificate is useless without network access to the target host. The certificate TTL (default 8h, configurable) bounds any residual risk in edge cases, but in practice revocation is effective immediately at the ziti layer.

Shared vs per-identity mode

Two modes.

  • Shared: all users authenticate as one Linux account (e.g. ziggy) โ€” simple, no user provisioning, obviously less secure, but also less complex to implement.
  • Per-identity: the CA derives a Linux username from the ziti identity name and uses it as the certificate principal. ziti-ssh-host creates ephemeral Linux users on connect and applies per-identity groups and sudoers rules from a ziti-ssh-host.v1 config attached to the ziti service, so permission scope is tied to the service boundary.

Happy to answer questions. The project is new and contributions are welcome.

Hi Edward,

cool stuff! I will try that out tomorrow. I vibecoded myself a rust client today as well that spawns an authenticated shell via HTTP/2 :smiley:
I will do some testing with ziti-ssh.
Any reason why the cert lives for 8h by default? Typically while connecting it's only necessary to be valid for a couple seconds, so anything like 2-3 minutes should be plenty or am I missing something?

Thanks!

No reason I can remember(other that it was a 8hr workday), but I completely agree & so the new default will be 5minutes(it's adjustable for anyone that wants it higher/lower).

Looking forward to more feedback!

Thanks!

This doesn't really belong here but for my testing yesterday I built a FFI of the ziti-sdk-c for rust.
In case you want to clone it or use it, feel free to do so. It should feature all functions available in zitilib.h from the the ziti-sdk-c as well: GitHub - dmuensterer/ziti-sdk-rust ยท GitHub

I just pushed the packages into the NetFoundry repository! The install is a whole lot easier now with just a single command to setup the repo & install the packages:

curl -sSL https://get.netfoundry.io/linux-install.bash | sudo bash -s {package name}

Example:

curl -sSL https://get.netfoundry.io/linux-install.bash | sudo bash -s ziti-ssh-host

All the docs have been update as well to use the above.