Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: tls middleware #121

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions pkg/apiserver/middlewares/v1/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,21 @@ func HashSHA512(str string) string {

func (a *APIKey) authTLS(c *gin.Context, logger *log.Entry) *ent.Bouncer {
if a.TlsAuth == nil {
// XXX: or warn?
logger.Error("TLS Auth is not configured but client presented a certificate")
return nil
}

validCert, extractedCN, err := a.TlsAuth.ValidateCert(c)
if !validCert {
if err != nil {
// XXX: or warn?
logger.Error(err)
return nil
}

if err != nil {
logger.Error(err)
if !validCert {
// XXX: or warn?
logger.Errorf("invalid certificate presented by bouncer")
return nil
}

Expand Down
61 changes: 61 additions & 0 deletions pkg/apiserver/middlewares/v1/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package v1

import (
"sync"
"time"

log "github.com/sirupsen/logrus"
)

type cacheEntry struct {
revoked bool
timestamp time.Time
}

type RevocationCache struct {
mu sync.RWMutex
cache map[string]cacheEntry
expiration time.Duration
}

func NewRevocationCache(expiration time.Duration) *RevocationCache {
return &RevocationCache{
cache: make(map[string]cacheEntry),
expiration: expiration,
}
}

func (rc *RevocationCache) Get(sn string, logger *log.Entry) (bool, bool) {
rc.mu.RLock()
entry, exists := rc.cache[sn]
rc.mu.RUnlock()

if !exists {
logger.Tracef("TLSAuth: no cached value for cert %s", sn)
return false, false
}

rc.mu.Lock()
defer rc.mu.Unlock()

if entry.timestamp.Add(rc.expiration).Before(time.Now()) {
logger.Debugf("TLSAuth: cached value for %s expired, removing from cache", sn)
delete(rc.cache, sn)

return false, false
}

logger.Debugf("TLSAuth: using cached value for cert %s: %t", sn, entry.revoked)

return entry.revoked, true
}

func (rc *RevocationCache) Set(sn string, revoked bool) {
rc.mu.Lock()
defer rc.mu.Unlock()

rc.cache[sn] = cacheEntry{
revoked: revoked,
timestamp: time.Now(),
}
}
134 changes: 134 additions & 0 deletions pkg/apiserver/middlewares/v1/crl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package v1

import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"sync"
"time"

log "github.com/sirupsen/logrus"
)

type CRLChecker struct {
path string
fileInfo os.FileInfo
crls []*x509.RevocationList
logger *log.Entry
mu sync.RWMutex
lastChecked time.Time
}

func NewCRLChecker(crlPath string, logger *log.Entry) (*CRLChecker, error) {
cc := &CRLChecker{
path: crlPath,
logger: logger,
}

err := cc.refresh()
if err != nil {
return nil, err
}

return cc, nil
}

func (*CRLChecker) decodeCRLs(content []byte, logger *log.Entry) []*x509.RevocationList {
var crls []*x509.RevocationList

for {
block, rest := pem.Decode(content)
if block == nil {
break // no more PEM blocks
}

content = rest

crl, err := x509.ParseRevocationList(block.Bytes)
if err != nil {
logger.Errorf("could not parse a PEM block in CRL file, skipping: %s", err)
continue
}

crls = append(crls, crl)
}

return crls
}

// refresh() reads the CRL file if new or changed since the last time
func (cc *CRLChecker) refresh() error {
// noop if lastChecked is less than 5 seconds ago
if time.Since(cc.lastChecked) < 5*time.Second {
return nil
}

cc.mu.Lock()
defer cc.mu.Unlock()

cc.logger.Debugf("loading CRL file from %s", cc.path)

fileInfo, err := os.Stat(cc.path)
if err != nil {
return fmt.Errorf("could not access CRL file: %w", err)
}

// noop if the file didn't change
if cc.fileInfo != nil && fileInfo.ModTime().Equal(cc.fileInfo.ModTime()) && fileInfo.Size() == cc.fileInfo.Size() {
return nil
}

// the encoding/pem package wants bytes, not io.Reader
crlContent, err := os.ReadFile(cc.path)
if err != nil {
return fmt.Errorf("could not read CRL file: %w", err)
}

cc.crls = cc.decodeCRLs(crlContent, cc.logger)
cc.fileInfo = fileInfo
cc.lastChecked = time.Now()

return nil
}

// isRevoked checks if the client certificate is revoked by any of the CRL blocks
// It returns a boolean indicating if the certificate is revoked and a boolean indicating
// if the CRL check was successful and could be cached.
func (cc *CRLChecker) isRevoked(cert *x509.Certificate) (bool, bool) {
if cc == nil {
return false, true
}

err := cc.refresh()
if err != nil {
// we can't quit obviously, so we just log the error and continue
// but we can assume we have loaded a CRL, or it would have quit the first time
cc.logger.Errorf("while refreshing CRL: %s - will keep using CRL file read at %s", err,
cc.lastChecked.Format(time.RFC3339))
}

now := time.Now().UTC()

cc.mu.RLock()
defer cc.mu.RUnlock()

for _, crl := range cc.crls {
if now.After(crl.NextUpdate) {
cc.logger.Warn("CRL has expired, will still validate the cert against it.")
}

if now.Before(crl.ThisUpdate) {
cc.logger.Warn("CRL is not yet valid, will still validate the cert against it.")
}

for _, revoked := range crl.RevokedCertificateEntries {
if revoked.SerialNumber.Cmp(cert.SerialNumber) == 0 {
cc.logger.Warn("client certificate is revoked by CRL")
return true, true
}
}
}

return false, true
}
100 changes: 100 additions & 0 deletions pkg/apiserver/middlewares/v1/ocsp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package v1

import (
"bytes"
"crypto"
"crypto/x509"
"io"
"net/http"
"net/url"

log "github.com/sirupsen/logrus"
"golang.org/x/crypto/ocsp"
)

type OCSPChecker struct {
logger *log.Entry
}

func NewOCSPChecker(logger *log.Entry) *OCSPChecker {
return &OCSPChecker{
logger: logger,
}
}

func (oc *OCSPChecker) query(server string, cert *x509.Certificate, issuer *x509.Certificate) (*ocsp.Response, error) {
req, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{Hash: crypto.SHA256})
if err != nil {
oc.logger.Errorf("TLSAuth: error creating OCSP request: %s", err)
return nil, err
}

httpRequest, err := http.NewRequest(http.MethodPost, server, bytes.NewBuffer(req))
if err != nil {
oc.logger.Error("TLSAuth: cannot create HTTP request for OCSP")
return nil, err
}

ocspURL, err := url.Parse(server)
if err != nil {
oc.logger.Error("TLSAuth: cannot parse OCSP URL")
return nil, err
}

httpRequest.Header.Add("Content-Type", "application/ocsp-request")
httpRequest.Header.Add("Accept", "application/ocsp-response")
httpRequest.Header.Add("host", ocspURL.Host)

httpClient := &http.Client{}

// XXX: timeout, context?
httpResponse, err := httpClient.Do(httpRequest)
if err != nil {
oc.logger.Error("TLSAuth: cannot send HTTP request to OCSP")
return nil, err
}
defer httpResponse.Body.Close()

output, err := io.ReadAll(httpResponse.Body)
if err != nil {
oc.logger.Error("TLSAuth: cannot read HTTP response from OCSP")
return nil, err
}

ocspResponse, err := ocsp.ParseResponseForCert(output, cert, issuer)

return ocspResponse, err
}

// isRevokedBy checks if the client certificate is revoked by the issuer via any of the OCSP servers present in the certificate.
// It returns a boolean indicating if the certificate is revoked and a boolean indicating
// if the OCSP check was successful and could be cached.
func (oc *OCSPChecker) isRevokedBy(cert *x509.Certificate, issuer *x509.Certificate) (bool, bool) {
if cert.OCSPServer == nil || len(cert.OCSPServer) == 0 {
oc.logger.Infof("TLSAuth: no OCSP Server present in client certificate, skipping OCSP verification")
return false, true
}

for _, server := range cert.OCSPServer {
ocspResponse, err := oc.query(server, cert, issuer)
if err != nil {
oc.logger.Errorf("TLSAuth: error querying OCSP server %s: %s", server, err)
continue
}

switch ocspResponse.Status {
case ocsp.Good:
return false, true
case ocsp.Revoked:
oc.logger.Errorf("TLSAuth: client certificate is revoked by server %s", server)
return true, true
case ocsp.Unknown:
log.Debugf("unknown OCSP status for server %s", server)
continue
}
}

log.Infof("Could not get any valid OCSP response, assuming the cert is revoked")

return true, false
}
Loading
Loading