Skip to content

Commit 1200035

Browse files
authored
Merge pull request smallstep#849 from smallstep/feat/renewAfterExpiry
Renew After Expiry
2 parents 7a13661 + 915911e commit 1200035

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+2203
-734
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
66

77
## [Unreleased - 0.18.3] - DATE
88
### Added
9+
- Added support for renew after expiry using the claim `allowRenewAfterExpiry`.
910
### Changed
1011
- Made SCEP CA URL paths dynamic
1112
### Deprecated

api/api.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ type Authority interface {
3333
// context specifies the Authorize[Sign|Revoke|etc.] method.
3434
Authorize(ctx context.Context, ott string) ([]provisioner.SignOption, error)
3535
AuthorizeSign(ott string) ([]provisioner.SignOption, error)
36+
AuthorizeRenewToken(ctx context.Context, ott string) (*x509.Certificate, error)
3637
GetTLSOptions() *config.TLSOptions
3738
Root(shasum string) (*x509.Certificate, error)
3839
Sign(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error)
@@ -43,7 +44,7 @@ type Authority interface {
4344
GetProvisioners(cursor string, limit int) (provisioner.List, string, error)
4445
Revoke(context.Context, *authority.RevokeOptions) error
4546
GetEncryptedKey(kid string) (string, error)
46-
GetRoots() (federation []*x509.Certificate, err error)
47+
GetRoots() ([]*x509.Certificate, error)
4748
GetFederation() ([]*x509.Certificate, error)
4849
Version() authority.Version
4950
}

api/api_test.go

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"crypto/tls"
1414
"crypto/x509"
1515
"crypto/x509/pkix"
16+
"encoding/base64"
1617
"encoding/json"
1718
"encoding/pem"
1819
"fmt"
@@ -34,6 +35,7 @@ import (
3435
"github.com/smallstep/certificates/logging"
3536
"github.com/smallstep/certificates/templates"
3637
"go.step.sm/crypto/jose"
38+
"go.step.sm/crypto/x509util"
3739
"golang.org/x/crypto/ssh"
3840
)
3941

@@ -171,6 +173,7 @@ type mockAuthority struct {
171173
ret1, ret2 interface{}
172174
err error
173175
authorizeSign func(ott string) ([]provisioner.SignOption, error)
176+
authorizeRenewToken func(ctx context.Context, ott string) (*x509.Certificate, error)
174177
getTLSOptions func() *authority.TLSOptions
175178
root func(shasum string) (*x509.Certificate, error)
176179
sign func(cr *x509.CertificateRequest, opts provisioner.SignOptions, signOpts ...provisioner.SignOption) ([]*x509.Certificate, error)
@@ -208,6 +211,13 @@ func (m *mockAuthority) AuthorizeSign(ott string) ([]provisioner.SignOption, err
208211
return m.ret1.([]provisioner.SignOption), m.err
209212
}
210213

214+
func (m *mockAuthority) AuthorizeRenewToken(ctx context.Context, ott string) (*x509.Certificate, error) {
215+
if m.authorizeRenewToken != nil {
216+
return m.authorizeRenewToken(ctx, ott)
217+
}
218+
return m.ret1.(*x509.Certificate), m.err
219+
}
220+
211221
func (m *mockAuthority) GetTLSOptions() *authority.TLSOptions {
212222
if m.getTLSOptions != nil {
213223
return m.getTLSOptions()
@@ -920,48 +930,141 @@ func Test_caHandler_Renew(t *testing.T) {
920930
cs := &tls.ConnectionState{
921931
PeerCertificates: []*x509.Certificate{parseCertificate(certPEM)},
922932
}
933+
934+
// Prepare root and leaf for renew after expiry test.
935+
now := time.Now()
936+
rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader)
937+
if err != nil {
938+
t.Fatal(err)
939+
}
940+
leafPub, leafPriv, err := ed25519.GenerateKey(rand.Reader)
941+
if err != nil {
942+
t.Fatal(err)
943+
}
944+
root := &x509.Certificate{
945+
Subject: pkix.Name{CommonName: "Test Root CA"},
946+
PublicKey: rootPub,
947+
KeyUsage: x509.KeyUsageCertSign,
948+
BasicConstraintsValid: true,
949+
IsCA: true,
950+
NotBefore: now.Add(-2 * time.Hour),
951+
NotAfter: now.Add(time.Hour),
952+
}
953+
root, err = x509util.CreateCertificate(root, root, rootPub, rootPriv)
954+
if err != nil {
955+
t.Fatal(err)
956+
}
957+
expiredLeaf := &x509.Certificate{
958+
Subject: pkix.Name{CommonName: "Leaf certificate"},
959+
PublicKey: leafPub,
960+
KeyUsage: x509.KeyUsageDigitalSignature,
961+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
962+
NotBefore: now.Add(-time.Hour),
963+
NotAfter: now.Add(-time.Minute),
964+
EmailAddresses: []string{"test@example.org"},
965+
}
966+
expiredLeaf, err = x509util.CreateCertificate(expiredLeaf, root, leafPub, rootPriv)
967+
if err != nil {
968+
t.Fatal(err)
969+
}
970+
971+
// Generate renew after expiry token
972+
so := new(jose.SignerOptions)
973+
so.WithType("JWT")
974+
so.WithHeader("x5cInsecure", []string{base64.StdEncoding.EncodeToString(expiredLeaf.Raw)})
975+
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.EdDSA, Key: leafPriv}, so)
976+
if err != nil {
977+
t.Fatal(err)
978+
}
979+
generateX5cToken := func(claims jose.Claims) string {
980+
s, err := jose.Signed(sig).Claims(claims).CompactSerialize()
981+
if err != nil {
982+
t.Fatal(err)
983+
}
984+
return s
985+
}
986+
923987
tests := []struct {
924988
name string
925989
tls *tls.ConnectionState
990+
header http.Header
926991
cert *x509.Certificate
927992
root *x509.Certificate
928993
err error
929994
statusCode int
930995
}{
931-
{"ok", cs, parseCertificate(certPEM), parseCertificate(rootPEM), nil, http.StatusCreated},
932-
{"no tls", nil, nil, nil, nil, http.StatusBadRequest},
933-
{"no peer certificates", &tls.ConnectionState{}, nil, nil, nil, http.StatusBadRequest},
934-
{"renew error", cs, nil, nil, errs.Forbidden("an error"), http.StatusForbidden},
996+
{"ok", cs, nil, parseCertificate(certPEM), parseCertificate(rootPEM), nil, http.StatusCreated},
997+
{"ok renew after expiry", &tls.ConnectionState{}, http.Header{
998+
"Authorization": []string{"Bearer " + generateX5cToken(jose.Claims{
999+
NotBefore: jose.NewNumericDate(now), Expiry: jose.NewNumericDate(now.Add(5 * time.Minute)),
1000+
})},
1001+
}, expiredLeaf, root, nil, http.StatusCreated},
1002+
{"no tls", nil, nil, nil, nil, nil, http.StatusBadRequest},
1003+
{"no peer certificates", &tls.ConnectionState{}, nil, nil, nil, nil, http.StatusBadRequest},
1004+
{"renew error", cs, nil, nil, nil, errs.Forbidden("an error"), http.StatusForbidden},
1005+
{"fail expired token", &tls.ConnectionState{}, http.Header{
1006+
"Authorization": []string{"Bearer " + generateX5cToken(jose.Claims{
1007+
NotBefore: jose.NewNumericDate(now.Add(-time.Hour)), Expiry: jose.NewNumericDate(now.Add(-time.Minute)),
1008+
})},
1009+
}, expiredLeaf, root, errs.Forbidden("an error"), http.StatusUnauthorized},
1010+
{"fail invalid root", &tls.ConnectionState{}, http.Header{
1011+
"Authorization": []string{"Bearer " + generateX5cToken(jose.Claims{
1012+
NotBefore: jose.NewNumericDate(now.Add(-time.Hour)), Expiry: jose.NewNumericDate(now.Add(-time.Minute)),
1013+
})},
1014+
}, expiredLeaf, parseCertificate(rootPEM), errs.Forbidden("an error"), http.StatusUnauthorized},
9351015
}
9361016

937-
expected := []byte(`{"crt":"` + strings.ReplaceAll(certPEM, "\n", `\n`) + `\n","ca":"` + strings.ReplaceAll(rootPEM, "\n", `\n`) + `\n","certChain":["` + strings.ReplaceAll(certPEM, "\n", `\n`) + `\n","` + strings.ReplaceAll(rootPEM, "\n", `\n`) + `\n"]}`)
938-
9391017
for _, tt := range tests {
9401018
t.Run(tt.name, func(t *testing.T) {
9411019
h := New(&mockAuthority{
9421020
ret1: tt.cert, ret2: tt.root, err: tt.err,
1021+
authorizeRenewToken: func(ctx context.Context, ott string) (*x509.Certificate, error) {
1022+
jwt, chain, err := jose.ParseX5cInsecure(ott, []*x509.Certificate{tt.root})
1023+
if err != nil {
1024+
return nil, errs.Unauthorized(err.Error())
1025+
}
1026+
var claims jose.Claims
1027+
if err := jwt.Claims(chain[0][0].PublicKey, &claims); err != nil {
1028+
return nil, errs.Unauthorized(err.Error())
1029+
}
1030+
if err := claims.ValidateWithLeeway(jose.Expected{
1031+
Time: now,
1032+
}, time.Minute); err != nil {
1033+
return nil, errs.Unauthorized(err.Error())
1034+
}
1035+
return chain[0][0], nil
1036+
},
9431037
getTLSOptions: func() *authority.TLSOptions {
9441038
return nil
9451039
},
9461040
}).(*caHandler)
9471041
req := httptest.NewRequest("POST", "http://example.com/renew", nil)
9481042
req.TLS = tt.tls
1043+
req.Header = tt.header
9491044
w := httptest.NewRecorder()
9501045
h.Renew(logging.NewResponseLogger(w), req)
951-
res := w.Result()
9521046

953-
if res.StatusCode != tt.statusCode {
954-
t.Errorf("caHandler.Renew StatusCode = %d, wants %d", res.StatusCode, tt.statusCode)
955-
}
1047+
res := w.Result()
1048+
defer res.Body.Close()
9561049

9571050
body, err := io.ReadAll(res.Body)
958-
res.Body.Close()
9591051
if err != nil {
9601052
t.Errorf("caHandler.Renew unexpected error = %v", err)
9611053
}
1054+
if res.StatusCode != tt.statusCode {
1055+
t.Errorf("caHandler.Renew StatusCode = %d, wants %d", res.StatusCode, tt.statusCode)
1056+
t.Errorf("%s", body)
1057+
}
1058+
9621059
if tt.statusCode < http.StatusBadRequest {
1060+
expected := []byte(`{"crt":"` + strings.ReplaceAll(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: tt.cert.Raw})), "\n", `\n`) + `",` +
1061+
`"ca":"` + strings.ReplaceAll(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: tt.root.Raw})), "\n", `\n`) + `",` +
1062+
`"certChain":["` +
1063+
strings.ReplaceAll(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: tt.cert.Raw})), "\n", `\n`) + `","` +
1064+
strings.ReplaceAll(string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: tt.root.Raw})), "\n", `\n`) + `"]}`)
1065+
9631066
if !bytes.Equal(bytes.TrimSpace(body), expected) {
964-
t.Errorf("caHandler.Root Body = %s, wants %s", body, expected)
1067+
t.Errorf("caHandler.Root Body = \n%s, wants \n%s", body, expected)
9651068
}
9661069
}
9671070
})

api/renew.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
package api
22

33
import (
4+
"crypto/x509"
45
"net/http"
6+
"strings"
57

68
"github.com/smallstep/certificates/errs"
79
)
810

11+
const (
12+
authorizationHeader = "Authorization"
13+
bearerScheme = "Bearer"
14+
)
15+
916
// Renew uses the information of certificate in the TLS connection to create a
1017
// new one.
1118
func (h *caHandler) Renew(w http.ResponseWriter, r *http.Request) {
12-
if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
13-
WriteError(w, errs.BadRequest("missing client certificate"))
19+
cert, err := h.getPeerCertificate(r)
20+
if err != nil {
21+
WriteError(w, err)
1422
return
1523
}
1624

17-
certChain, err := h.Authority.Renew(r.TLS.PeerCertificates[0])
25+
certChain, err := h.Authority.Renew(cert)
1826
if err != nil {
1927
WriteError(w, errs.Wrap(http.StatusInternalServerError, err, "cahandler.Renew"))
2028
return
@@ -33,3 +41,15 @@ func (h *caHandler) Renew(w http.ResponseWriter, r *http.Request) {
3341
TLSOptions: h.Authority.GetTLSOptions(),
3442
}, http.StatusCreated)
3543
}
44+
45+
func (h *caHandler) getPeerCertificate(r *http.Request) (*x509.Certificate, error) {
46+
if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
47+
return r.TLS.PeerCertificates[0], nil
48+
}
49+
if s := r.Header.Get(authorizationHeader); s != "" {
50+
if parts := strings.SplitN(s, bearerScheme+" ", 2); len(parts) == 2 {
51+
return h.Authority.AuthorizeRenewToken(r.Context(), parts[1])
52+
}
53+
}
54+
return nil, errs.BadRequest("missing client certificate")
55+
}

authority/authority.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,12 @@ type Authority struct {
7070
startTime time.Time
7171

7272
// Custom functions
73-
sshBastionFunc func(ctx context.Context, user, hostname string) (*config.Bastion, error)
74-
sshCheckHostFunc func(ctx context.Context, principal string, tok string, roots []*x509.Certificate) (bool, error)
75-
sshGetHostsFunc func(ctx context.Context, cert *x509.Certificate) ([]config.Host, error)
76-
getIdentityFunc provisioner.GetIdentityFunc
73+
sshBastionFunc func(ctx context.Context, user, hostname string) (*config.Bastion, error)
74+
sshCheckHostFunc func(ctx context.Context, principal string, tok string, roots []*x509.Certificate) (bool, error)
75+
sshGetHostsFunc func(ctx context.Context, cert *x509.Certificate) ([]config.Host, error)
76+
getIdentityFunc provisioner.GetIdentityFunc
77+
authorizeRenewFunc provisioner.AuthorizeRenewFunc
78+
authorizeSSHRenewFunc provisioner.AuthorizeSSHRenewFunc
7779

7880
adminMutex sync.RWMutex
7981
}

authority/authorize.go

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"crypto/x509"
77
"encoding/hex"
88
"net/http"
9+
"net/url"
910
"strconv"
1011
"strings"
1112
"time"
@@ -276,14 +277,14 @@ func (a *Authority) authorizeRevoke(ctx context.Context, token string) error {
276277
func (a *Authority) authorizeRenew(cert *x509.Certificate) error {
277278
serial := cert.SerialNumber.String()
278279
var opts = []interface{}{errs.WithKeyVal("serialNumber", serial)}
280+
279281
isRevoked, err := a.IsRevoked(serial)
280282
if err != nil {
281283
return errs.Wrap(http.StatusInternalServerError, err, "authority.authorizeRenew", opts...)
282284
}
283285
if isRevoked {
284286
return errs.Unauthorized("authority.authorizeRenew: certificate has been revoked", opts...)
285287
}
286-
287288
p, ok := a.provisioners.LoadByCertificate(cert)
288289
if !ok {
289290
return errs.Unauthorized("authority.authorizeRenew: provisioner not found", opts...)
@@ -371,3 +372,80 @@ func (a *Authority) authorizeSSHRevoke(ctx context.Context, token string) error
371372
}
372373
return nil
373374
}
375+
376+
// AuthorizeRenewToken validates the renew token and returns the leaf
377+
// certificate in the x5cInsecure header.
378+
func (a *Authority) AuthorizeRenewToken(ctx context.Context, ott string) (*x509.Certificate, error) {
379+
var claims jose.Claims
380+
jwt, chain, err := jose.ParseX5cInsecure(ott, a.rootX509Certs)
381+
if err != nil {
382+
return nil, errs.UnauthorizedErr(err, errs.WithMessage("error validating renew token"))
383+
}
384+
leaf := chain[0][0]
385+
if err := jwt.Claims(leaf.PublicKey, &claims); err != nil {
386+
return nil, errs.InternalServerErr(err, errs.WithMessage("error validating renew token"))
387+
}
388+
389+
p, ok := a.provisioners.LoadByCertificate(leaf)
390+
if !ok {
391+
return nil, errs.Unauthorized("error validating renew token: cannot get provisioner from certificate")
392+
}
393+
if err := a.UseToken(ott, p); err != nil {
394+
return nil, err
395+
}
396+
397+
if err := claims.ValidateWithLeeway(jose.Expected{
398+
Issuer: p.GetName(),
399+
Subject: leaf.Subject.CommonName,
400+
Time: time.Now().UTC(),
401+
}, time.Minute); err != nil {
402+
switch err {
403+
case jose.ErrInvalidIssuer:
404+
return nil, errs.UnauthorizedErr(err, errs.WithMessage("error validating renew token: invalid issuer claim (iss)"))
405+
case jose.ErrInvalidSubject:
406+
return nil, errs.UnauthorizedErr(err, errs.WithMessage("error validating renew token: invalid subject claim (sub)"))
407+
case jose.ErrNotValidYet:
408+
return nil, errs.UnauthorizedErr(err, errs.WithMessage("error validating renew token: token not valid yet (nbf)"))
409+
case jose.ErrExpired:
410+
return nil, errs.UnauthorizedErr(err, errs.WithMessage("error validating renew token: token is expired (exp)"))
411+
case jose.ErrIssuedInTheFuture:
412+
return nil, errs.UnauthorizedErr(err, errs.WithMessage("error validating renew token: token issued in the future (iat)"))
413+
default:
414+
return nil, errs.UnauthorizedErr(err, errs.WithMessage("error validating renew token"))
415+
}
416+
}
417+
418+
audiences := a.config.GetAudiences().Renew
419+
if !matchesAudience(claims.Audience, audiences) {
420+
return nil, errs.InternalServerErr(err, errs.WithMessage("error validating renew token: invalid audience claim (aud)"))
421+
}
422+
423+
return leaf, nil
424+
}
425+
426+
// matchesAudience returns true if A and B share at least one element.
427+
func matchesAudience(as, bs []string) bool {
428+
if len(bs) == 0 || len(as) == 0 {
429+
return false
430+
}
431+
432+
for _, b := range bs {
433+
for _, a := range as {
434+
if b == a || stripPort(a) == stripPort(b) {
435+
return true
436+
}
437+
}
438+
}
439+
return false
440+
}
441+
442+
// stripPort attempts to strip the port from the given url. If parsing the url
443+
// produces errors it will just return the passed argument.
444+
func stripPort(rawurl string) string {
445+
u, err := url.Parse(rawurl)
446+
if err != nil {
447+
return rawurl
448+
}
449+
u.Host = u.Hostname()
450+
return u.String()
451+
}

0 commit comments

Comments
 (0)