Clever Uses of SSH Certificate Templates
Carl Tashian
SSH certificates are very powerful, and our early support for them in step-ca
only included the most minimal set of features needed for certificate authentication using user and host certificates. So back in August, after adding X.509 certificate templates, we snuck in support for SSH certificate templates to step-ca
version 0.15.2. We're glad to finally announce and document it.
SSH certificate templates are similar to X.509 templates: They are JSON files written with Go text/template
that you can use to customize the SSH certificates issued by step-ca
. In this post, I'll give you an overview of SSH certificate templates, and we'll walk through a few examples.
Here's what the default SSH user certificate template looks like:
{ "type": {{ toJson .Type }}, "keyId": {{ toJson .KeyID }}, "principals": {{ toJson .Principals }}, "extensions": {{ toJson .Extensions }}, "criticalOptions": {{ toJson .CriticalOptions }} }
And here's an SSH certificate issued using that template:
$ step ssh inspect id_ct-cert.pub id_ct-cert.pub: Type: ecdsa-sha2-nistp256-cert-v01@openssh.com user certificate Public key: ECDSA-CERT SHA256:iczSh1XiBBE36yfJcDidgp6fqY3qWx1RtEwFfAN9jDs Signing CA: ECDSA SHA256:MKwRQ/SDKk/pCJbbCk5bfhZACjSjv7uZXLyc5n4Wx6k Key ID: "carl@smallstep.com" Serial: 2831574724231262409 Valid: from 2020-11-17T16:48:11 to 2020-11-18T08:49:11 Principals: carl carl@smallstep.com Critical Options: (none) Extensions: permit-X11-forwarding permit-agent-forwarding permit-port-forwarding permit-pty permit-user-rc
This will authenticate me (as user carl
or carl@smallstep.com
) to any SSH host that trusts my SSH CA, and it includes some basic extensions:
permit-x11-forwarding
: Permit X11 forwarding (usingssh -X
) to run remote X11 programs on a local display.permit-agent-forwarding
: Permit Agent forwarding (usingssh -A
) to forward local SSH agent keys to the remote host. (See our blog post SSH Agent Explained for more.)permit-port-forwarding
: Permit port forwardings (tunnels) from local to remote (usingssh -L
) or from remote to local (usingssh -R
)permit-pty
: This one's pretty important. If you want an interactive shell session, you need a pty (a pseudo-tty) to be allocated to you by the host. The alternative is a remote host that doesn't allow any interactivity. For example, you can runssh -T git@github.com
to test your GitHub SSH authentication (-T
disables the request for a pty)permit-user-rc
: Run a personal RC file upon connection (located in~/.ssh/rc
on the remote)
Both user and host certificates support extensions and critical options, but OpenSSH does not define any built-in extensions or critical options for host certificates. So, user certificates are where all the fun stuff can happen, and in this post we'll only be looking at those.
Example Certificate Templates
Now let's a few some modifications to the default template.
Warning: Always wrap variables in toJson. In these templates, variables are pulled into templates using
{{ toJson .variableName }}
to sanitize the value of the variable. When using templates, you must sanitize all variables usingtoJson
to avoid template injection vulnerabilities.
Disallow Agent & Port Forwarding
In an environment where your users connect to internal hosts through a bastion, it can be useful to disallow port forwarding for security reasons. You may not want your users tunneling the production MySQL server port to their local host. Similarly, because agent forwarding comes with a security risk, you may not want to allow that. Here's an SSH certificate template that simply removes those two extensions:
{ "type": {{ toJson .Type }}, "keyId": {{ toJson .KeyID }}, "principals": {{ toJson .Principals }}, "extensions": { "permit-x11-forwarding": "", "permit-pty": "", "permit-user-rc": "" }, "criticalOptions": {{ toJson .CriticalOptions }} }
Embed a force-command
ForceCommand
is a server-side SSHD configuration directive that forces an alternative command to run on the host, in lieu of an interactive terminal. But you can embed force-command
as a Critical Option in a certificate, and it will have the same effect. This could be useful for service accounts that need to run one command only, such as triggering a job on a remote system.
Restrict connections by source-address
You can also embed a list of permitted source IPs (CIDR blocks) into a certificate to further restrict its use.
Here's a certificate template that uses both source-address
and force-command
:
{ "type": {{ toJson .Type }}, "keyId": {{ toJson .KeyID }}, "principals": {{ toJson .Principals }}, "extensions": {{ toJson .Extensions }}, "criticalOptions": { "force-command": "echo \"Hello World\"", "source-address": "10.20.30.0/24,1.1.1.1/32" } }
This is a fine example, but it's not dynamic. Most likely we would want a different list of source addresses for each user, for example. Let's try it...
Injecting user-specific values
Obviously we don't want the users to be able to specify their own value for source address. We want dynamic values that come from a trusted source.
To do this, we can leverage the OpenID Connect (OIDC) provisioner in step-ca
, and configure our OAuth provider to add a custom claim to our ID token, containing the source-address
CIRD blocks we want to add to that user's certificate.
The OIDC provisioner in step-ca
is already an ideal way to issue SSH certificates. In my DIY Single Sign-On for SSH post, I walk through how to set up an SSH CA that will exchange ID tokens from a trusted OAuth provider for short-lived SSH certificates. Once step-ca
is configured as a trusted OAuth client, it will read the email
field from the ID token and derive a list of SSH certificate principals (eg. an email
field containing carl@smallstep.com
will yield a certificate for carl
and carl@smallstep.com
).
But other fields can also be read from the ID token, in our template code. And (here's where the magic happens) OIDC allows for custom claims to be added to an ID token. So, on our identity provider we can add a custom source_address
field to our user directory and map that field to a custom claim in our ID token. Then, in the SSH template, we can inject the token value into the certificate. Here's the template:
{ "type": {{ toJson .Type }}, "keyId": {{ toJson .KeyID }}, "principals": {{ toJson .Principals }}, "extensions": {{ toJson .Extensions }}, {{ if .Token.source_address }} "criticalOptions": { "source-address": "{{ .Token.source_address }}" } {{ else }} "criticalOptions": {{ toJson .CriticalOptions }} {{ end }} }
User-specific GitHub Username
Let's another example that leverages custom claims. With GitHub Enterprise Cloud or GitHub Enterprise Server, you can configure GitHub to use SSH certificates. Specifically, GitHub will trust an SSH Certificate Authority for your team. But, to get it to work, you have to mint custom SSH certificates for your users that contain a login@github.com
extension, where the value of the extension is their GitHub Username. This custom extension value authenticates your users to GitHub Enterprise. Which is great: The same certificates that let you SSH into your servers now also let you push code.
Here's a template that supports the GitHub custom SSH certificate extension:
{ "type": {{ toJson .Type }}, "keyId": {{ toJson .KeyID }}, "principals": {{ toJson .Principals }}, "criticalOptions": {{ toJson .CriticalOptions }}, {{ if .Token.ghu }} "extensions": { "login@github.com": {{ toJson .Token.ghu }} } {{ else }} "extensions": {{ toJson .Extensions }} {{ end }} }
To use the template, you'll need to add a ghu
("GitHub Username") custom claim to your OIDC identity tokens. So, let's get into the details of how to create that custom claim with your OAuth provider.
Registering a custom claim with your identity provider
Not all identity providers support custom claims, but if they do, the process of defining one is pretty similar. Here's how you'd do it using Okta:
-
Add an OAuth application on Okta and configure a
step-ca
OIDC provisioner to trust it, as described in DIY SSO for SSH. -
Add a field to your Okta user directory (eg.
GitHub Username
) -
Add a Custom Claim to your OIDC token with a short name (eg.
ghu
) -
Populate the field for a test user, and check out your custom claim! Okta has an ID token testing tool. Or you can use
step
to test the whole OAuth flow:OIDC_ENDPOINT="https://[your organization].okta.com/oauth2/default/.well-known/openid-configuration" CLIENT_ID="[your OAuth client ID]" CLIENT_SECRET="[your OAuth client secret]" step oauth --oidc --provider $OIDC_ENDPOINT \ --client-id $CLIENT_ID --client-secret $CLIENT_SECRET \ --listen=":10000" --bare | step crypto jwt inspect --insecure
-
Finally, configure your
step-ca
to use the GitHub template shown above. Your provisioner configuration should reference the template file:{ "provisioners": [ { "type": "OIDC", "name": "Okta", "clientID": "[your OAuth client ID]", "clientSecret": "[your OAuth client secret]", "configurationEndpoint": "https://[your organization].okta.com/oauth2/default/.well-known/openid-configuration", "listenAddress": ":10000", "options": { "ssh": { "templateFile": "templates/certs/ssh/github.tpl" } } }, ... ] }
Next Steps
We've updated our documentation with a section about SSH templates, which goes over all the options and variables in greater detail. Looking for GitHub Enterprise SSH certificate support the easy way—without all of this setup and configuration? You can get set up in five minutes with our hosted Single Sign-On SSH product. Questions? We're here. Hit us up on GitHub Discussions.
Subscribe to updates
Unsubscribe anytime, see Privacy Policy
Carl Tashian (Website, LinkedIn) is an engineer, writer, exec coach, and startup all-rounder. He's currently an Offroad Engineer at Smallstep. He co-founded and built the engineering team at Trove, and he wrote the code that opens your Zipcar. He lives in San Francisco with his wife Siobhan and he loves to play the modular synthesizer 🎛️🎚️