Skip to content

Instantly share code, notes, and snippets.

@maraino
Last active August 11, 2021 21:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save maraino/4138feb21b0f05a5b4f0c6bc7c98832f to your computer and use it in GitHub Desktop.
Save maraino/4138feb21b0f05a5b4f0c6bc7c98832f to your computer and use it in GitHub Desktop.

Renew after expiry

This document describes the process to allow step-ca to renew a certificate after it has expired.

How renewals work

The classic way to renew a certificate on step-ca is to do an empty POST to the /renew endpoint using a valid client certificate. The request will go through an mTLS connection where the CA will validate the validity of the certificate, it will also check that the provisioner that created it exists and allows renewals, and finally the CA will sign a new certificate with almost the same information available in the old one. The serial number and the signature will change, and if the intermediate has changed the authority key identifier will too.

Using the cli you can do:

step ca renew --force internal.crt internal.key

With curl you will do:

curl --cacert $(step path)/certs/root_ca.crt --cert internal.crt --key internal.key -X POST https://ca.smallstep.com:9000/renew

And then just combine the response "certChain" property to create your renewed certificate.

step-ca also supports a rekey mechanism sending a Certificate Signing Request to /rekey using mTLS, this process will also sign a new certificate like before, but it will also replace the public key with the one in the CSR.

Options for renew after expiry

The first question that probably comes to mind is, can we use the same protocol with an expired certificate?

The short answer is yes, but it will mean to skip the certificate validation that Go is doing and do it manually, and this can be dangerous. We cannot skip the validation for just one or two endpoints, we'll have to do it at the server level. And to do it we will need to set tls.Config.ClientAuth = RequestClientCert and implement an empty tls.Config.VerifyPeerCertificate, at this point we can accept expired certificates, but then we'll have to add a middleware to validate client connections to all the endpoints, in case the client is using identity certificates, and a special middleware for the renewal endpoints that validates only the certificates for those provisioners where renew-after-expiry is not enabled, but accepting expired ones when the provisioner supports renew-after-expiry. To be clear, this is something that we're not doing.

The second option that comes to mind is if can we create a token with an x5c claim with the expired certificate and signed by the certificate key. Basically use a flow like the one used on X5C provisioners, but accepting expired certificates. Ok this can work, but right now the X5C provisioner does not signs a certificate based on the embedded one, a token for an X5C provisioners has the sans claim that contains the SANs that we want to grant.

We can continue to explore the X5C route but it will be probably better to create a new type of provisioner rather than having if conditions on the provisioner method, to validate a provisioner without taking into account the lifetime of the certificate as well as generate a "clone" of the embedded certificate instead of a new one from the SANs and a given CSR.

Renew after expiry provisioner

The configuration of a renew-after-expiry will be very similar to the X5C, we will need a string with the all the root certificates that we can validate, but if this is empty we can safely use the roots configured in the ca.json. So this two configurations will be totally valid:

{
    "type": "TBD",
    "name": "RenewAfterExpiry"
}
{
    "type": "TBD",
    "name": "RenewAfterExpiry",
    "roots": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JS..."
}

In the generated token we will validate:

  • The audience, a link to the ca with the provisioner in a fragment.
  • The issuer, the provisioner name.
  • The expiration and not before of the token with the current time.
  • The token subject. Should it match the embedded certificate common name?
  • The validity of the certificate in the x5c claim, taking into account:
    • The time when the certificate was issued (NotBefore).
    • The roots to validate the certificate with.
    • The key usage x5c.KeyUsageDigitalSignature as we will use this certificate to sign a token.
    • The extended key usage of the given certificate, X5C provisioner requires x5c.ExtKeyUsageClientAuth. Should we really require this? What if the certificate was generated with a provisioner that only uses the x509.ExtKeyUsageServerAuth extended key usage? Should we allow any?

This provisioner should also support the renewal of SSH certificates. In this case we cannot use roots as the public keys in those certs won't match the private key used to sign the SSH certificate. So we'll need to extend the provisioner with the properties sshHostCAs and sshUserCAs that contains a collection of public keys that we will accept. For this property we can use a list of ssh public keys that we want to support in SSH encoding.

With the default keys:

{
    "type": "TBD",
    "name": "RenewAfterExpiry",
    "claims": {
        "enableSSH": true
    }
}

With specific keys:

```json
{
    "type": "TBD",
    "name": "RenewAfterExpiry",
    "roots": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JS...",
    "sshHostCAs": [
        "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBAXhns07OVxxNxOVXoMuPcwJ87J45EBd8iweuRkLjKNzynn+scrVGr6+P3K5eDOJ2pafJa7EqORG3bn/BVAp+qQ=",
        "..."
    ],
    "sshUserCAs": [
        "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBClQDrRrVZh1Ani1Bj8I6GquFpN8QoivhSUbaPLMVEOYL8oZNrKqtmfJrG0/GDEIIyNbRZIsCe8l/FrTZs+6d4Q=",
        "..."
    ]
}

As we don't currently support the renewal of user certificates, the only key included by default will be the one used to sign host certificates in sshHostCas. The user will always have the option to add the user one and we can accept user certificates too.

The token for renewing ssh certificates will be similar to the one for X.509, but instead of having a x5c claim it will have the sshpop claim with the SSH certificate that we want to renew, and it will be signed by its private key.

The token validation will be similar to the X.509 one, with the following differences:

  • The token subject should match the certificate key id.
  • The sshpop claim should should be validate against the user or hosts CAs depending of the type of the certificate.

Finally the provisioner should probably implement AuthorizeRenew and AuthorizeSSHRenew and optionally AuthorizeRekey and AuthorizeSSHRekey if we want to support rekeys too.

API

Renew after expiry endpoints should re-use the /renew, /rekey and the SSH ones /ssh/renew and /ssh/rekey endpoints.

Right now the X.509 endpoints /renew and /rekey require a client certificate, but we can change them to either require an Authorization header like:

Authentication: Mutual <token>

If the token is given the renew and rekey endpoints will request the authority to authorize it using the new AuthorizeRenew and AuthorizeSSHRenew methods:

AuthorizeRenew(ctx context.Context, token string) (*x509.Certificate, error)
AuthorizeSSHRenew(ctx context.Context, token string) (*ssh.Certificate, error)

These methods will retrieve the appropriate provisioner for the token and call the new AuthorizeRenewByToken and the already existing AuthorizeSSHRenew implemented by the new renew-after-expiry provisioner:

type AuthorizeRenewerByToken interface {
    AuthorizeRenewByToken(ctx context.Context, token string) (*x509.Certificate, error)
}

type AuthorizeSSHRenewer interface {
    AuthorizeSSHRenew(ctx context.Context, token string) (*ssh.Certificate, error)
}

The AuthorizeRenewByToken can optionally be implemented by the X5C provisioner to be able to renew the certificate in the token. The SSHPOP provisioner already supports the AuthorizeSSHRenew, and we should change /ssh/renew and /ssh/rekey to use authority.AuthoritheSSHRenew instead of just authority.Authorize and then getting the certificate again from the token.

Alternatively we can change the current implementation of provisioner.AuthorizeRenew from:

AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error

to one of these options:

// Here the token is passed by context.
AuthorizeRenew(ctx context.Context, cert *x509.Certificate) (*x509.Certificate, error)
// Here the token is a parameter.
AuthorizeRenew(ctx context.Context, cert *x509.Certificate, token string) (*x509.Certificate, error)

Open questions

  • What would be the type of a renew-after-expiry provisioner? Or should we just extend the capabilities of X5C?
  • Should we use the /sign (and /ssh/sign) endpoint, the /renew and /rekey endpoints or new ones? Currently /sign requires a CRL, but renew-after-expiry doesn't really need one, although we can easily generate it as we will have access to the private key.
    • Let's use the old methods
  • Should we allow to renew any kind of certificate? Perhaps only the ones coming from a provisioner that has a special claim that allow after expiry, or perhaps a custom extension that is added if a provisioner has that claim.
  • If we use that special claim, do we have to set it in the renew after expiry provisioner, or is it true by default in this case?
  • Should we allow the use of templates in the renew after expiry provisioner? Renewal/Rekey endpoints do not support any kind of templates, so perhaps we don't want to do it.
  • Should we convert the roots property to a list instead of a string will all the certificates? Right now this is how it is implemented in the X5C provisioner but a list will match the list of ssh keys.
  • Should we use a JSON body instead of the Authorization header?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment