mirror of
https://github.com/crowdsecurity/crowdsec.git
synced 2025-05-11 12:25:53 +02:00
improved tls middleware revocation checks (#3034)
This commit is contained in:
parent
3097c0ce90
commit
a3bd9baec1
22 changed files with 850 additions and 471 deletions
|
@ -462,10 +462,6 @@ issues:
|
||||||
path: pkg/hubtest/hubtest_item.go
|
path: pkg/hubtest/hubtest_item.go
|
||||||
text: "cyclomatic: .*RunWithLogFile"
|
text: "cyclomatic: .*RunWithLogFile"
|
||||||
|
|
||||||
- linters:
|
|
||||||
- canonicalheader
|
|
||||||
path: pkg/apiserver/middlewares/v1/tls_auth.go
|
|
||||||
|
|
||||||
# tolerate complex functions in tests for now
|
# tolerate complex functions in tests for now
|
||||||
- linters:
|
- linters:
|
||||||
- maintidx
|
- maintidx
|
||||||
|
|
|
@ -281,7 +281,7 @@ def test_tls_client_ou(crowdsec, flavor, certs_dir):
|
||||||
lapi.wait_for_http(8080, '/health', want_status=None)
|
lapi.wait_for_http(8080, '/health', want_status=None)
|
||||||
with cs_agent as agent:
|
with cs_agent as agent:
|
||||||
lapi.wait_for_log([
|
lapi.wait_for_log([
|
||||||
"*client certificate OU (?custom-client-ou?) doesn't match expected OU (?agent-ou?)*",
|
"*client certificate OU ?custom-client-ou? doesn't match expected OU ?agent-ou?*",
|
||||||
])
|
])
|
||||||
|
|
||||||
lapi_env['AGENTS_ALLOWED_OU'] = 'custom-client-ou'
|
lapi_env['AGENTS_ALLOWED_OU'] = 'custom-client-ou'
|
||||||
|
|
|
@ -10,8 +10,6 @@ import (
|
||||||
"github.com/crowdsecurity/crowdsec/pkg/models"
|
"github.com/crowdsecurity/crowdsec/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// type ApiAlerts service
|
|
||||||
|
|
||||||
type AlertsService service
|
type AlertsService service
|
||||||
|
|
||||||
type AlertsListOpts struct {
|
type AlertsListOpts struct {
|
||||||
|
|
|
@ -8,8 +8,6 @@ import (
|
||||||
"github.com/crowdsecurity/crowdsec/pkg/models"
|
"github.com/crowdsecurity/crowdsec/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// type ApiAlerts service
|
|
||||||
|
|
||||||
type AuthService service
|
type AuthService service
|
||||||
|
|
||||||
// Don't add it to the models, as they are used with LAPI, but the enroll endpoint is specific to CAPI
|
// Don't add it to the models, as they are used with LAPI, but the enroll endpoint is specific to CAPI
|
||||||
|
|
|
@ -60,18 +60,13 @@ func HashSHA512(str string) string {
|
||||||
|
|
||||||
func (a *APIKey) authTLS(c *gin.Context, logger *log.Entry) *ent.Bouncer {
|
func (a *APIKey) authTLS(c *gin.Context, logger *log.Entry) *ent.Bouncer {
|
||||||
if a.TlsAuth == nil {
|
if a.TlsAuth == nil {
|
||||||
logger.Error("TLS Auth is not configured but client presented a certificate")
|
logger.Warn("TLS Auth is not configured but client presented a certificate")
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
validCert, extractedCN, err := a.TlsAuth.ValidateCert(c)
|
|
||||||
if !validCert {
|
|
||||||
logger.Error(err)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extractedCN, err := a.TlsAuth.ValidateCert(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err)
|
logger.Warn(err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -148,6 +143,7 @@ func (a *APIKey) MiddlewareFunc() gin.HandlerFunc {
|
||||||
}
|
}
|
||||||
|
|
||||||
if bouncer == nil {
|
if bouncer == nil {
|
||||||
|
// XXX: StatusUnauthorized?
|
||||||
c.JSON(http.StatusForbidden, gin.H{"message": "access forbidden"})
|
c.JSON(http.StatusForbidden, gin.H{"message": "access forbidden"})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
|
|
||||||
|
|
99
pkg/apiserver/middlewares/v1/cache.go
Normal file
99
pkg/apiserver/middlewares/v1/cache.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
err error // if nil, the certificate is not revocated
|
||||||
|
timestamp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type RevocationCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
cache map[string]cacheEntry
|
||||||
|
expiration time.Duration
|
||||||
|
lastPurge time.Time
|
||||||
|
logger *log.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRevocationCache(expiration time.Duration, logger *log.Entry) *RevocationCache {
|
||||||
|
return &RevocationCache{
|
||||||
|
cache: make(map[string]cacheEntry),
|
||||||
|
expiration: expiration,
|
||||||
|
lastPurge: time.Now(),
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*RevocationCache) generateKey(cert *x509.Certificate) string {
|
||||||
|
return cert.SerialNumber.String() + "-" + cert.Issuer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// purge removes expired entries from the cache
|
||||||
|
func (rc *RevocationCache) purgeExpired() {
|
||||||
|
// we don't keep a separate interval for the full sweep, we'll just double the expiration
|
||||||
|
if time.Since(rc.lastPurge) < rc.expiration {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.mu.Lock()
|
||||||
|
defer rc.mu.Unlock()
|
||||||
|
|
||||||
|
for key, entry := range rc.cache {
|
||||||
|
if time.Since(entry.timestamp) > rc.expiration {
|
||||||
|
rc.logger.Debugf("purging expired entry for cert %s", key)
|
||||||
|
delete(rc.cache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RevocationCache) Get(cert *x509.Certificate) (error, bool) { //nolint:revive
|
||||||
|
rc.purgeExpired()
|
||||||
|
key := rc.generateKey(cert)
|
||||||
|
rc.mu.RLock()
|
||||||
|
entry, exists := rc.cache[key]
|
||||||
|
rc.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
rc.logger.Tracef("no cached value for cert %s", key)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upgrade to write lock to potentially modify the cache
|
||||||
|
rc.mu.Lock()
|
||||||
|
defer rc.mu.Unlock()
|
||||||
|
|
||||||
|
if entry.timestamp.Add(rc.expiration).Before(time.Now()) {
|
||||||
|
rc.logger.Debugf("cached value for %s expired, removing from cache", key)
|
||||||
|
delete(rc.cache, key)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
rc.logger.Debugf("using cached value for cert %s: %v", key, entry.err)
|
||||||
|
|
||||||
|
return entry.err, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RevocationCache) Set(cert *x509.Certificate, err error) {
|
||||||
|
key := rc.generateKey(cert)
|
||||||
|
|
||||||
|
rc.mu.Lock()
|
||||||
|
defer rc.mu.Unlock()
|
||||||
|
|
||||||
|
rc.cache[key] = cacheEntry{
|
||||||
|
err: err,
|
||||||
|
timestamp: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rc *RevocationCache) Empty() {
|
||||||
|
rc.mu.Lock()
|
||||||
|
defer rc.mu.Unlock()
|
||||||
|
rc.cache = make(map[string]cacheEntry)
|
||||||
|
}
|
145
pkg/apiserver/middlewares/v1/crl.go
Normal file
145
pkg/apiserver/middlewares/v1/crl.go
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CRLChecker struct {
|
||||||
|
path string // path to the CRL file
|
||||||
|
fileInfo os.FileInfo // last stat of the CRL file
|
||||||
|
crls []*x509.RevocationList // parsed CRLs
|
||||||
|
logger *log.Entry
|
||||||
|
mu sync.RWMutex
|
||||||
|
lastLoad time.Time // time when the CRL file was last read successfully
|
||||||
|
onLoad func() // called when the CRL file changes (and is read successfully)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCRLChecker(crlPath string, onLoad func(), logger *log.Entry) (*CRLChecker, error) {
|
||||||
|
cc := &CRLChecker{
|
||||||
|
path: crlPath,
|
||||||
|
logger: logger,
|
||||||
|
onLoad: onLoad,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cc.refresh()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return cc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CRLChecker) decodeCRLs(content []byte) ([]*x509.RevocationList, error) {
|
||||||
|
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 {
|
||||||
|
// invalidate the whole CRL file so we can still use the previous version
|
||||||
|
return nil, fmt.Errorf("could not parse file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
crls = append(crls, crl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return crls, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh() reads the CRL file if new or changed since the last time
|
||||||
|
func (cc *CRLChecker) refresh() error {
|
||||||
|
// noop if lastLoad is less than 5 seconds ago
|
||||||
|
if time.Since(cc.lastLoad) < 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, err = cc.decodeCRLs(crlContent)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cc.fileInfo = fileInfo
|
||||||
|
cc.lastLoad = time.Now()
|
||||||
|
cc.onLoad()
|
||||||
|
|
||||||
|
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) isRevokedBy(cert *x509.Certificate, issuer *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.lastLoad.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
cc.mu.RLock()
|
||||||
|
defer cc.mu.RUnlock()
|
||||||
|
|
||||||
|
for _, crl := range cc.crls {
|
||||||
|
if err := crl.CheckSignatureFrom(issuer); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -59,27 +58,19 @@ func (j *JWT) authTLS(c *gin.Context) (*authInput, error) {
|
||||||
ret := authInput{}
|
ret := authInput{}
|
||||||
|
|
||||||
if j.TlsAuth == nil {
|
if j.TlsAuth == nil {
|
||||||
c.JSON(http.StatusForbidden, gin.H{"message": "access forbidden"})
|
err := errors.New("tls authentication required")
|
||||||
c.Abort()
|
log.Warn(err)
|
||||||
|
|
||||||
return nil, errors.New("TLS auth is not configured")
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
validCert, extractedCN, err := j.TlsAuth.ValidateCert(c)
|
extractedCN, err := j.TlsAuth.ValidateCert(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Warn(err)
|
||||||
c.JSON(http.StatusForbidden, gin.H{"message": "access forbidden"})
|
return nil, err
|
||||||
c.Abort()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("while trying to validate client cert: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !validCert {
|
logger := log.WithField("ip", c.ClientIP())
|
||||||
c.JSON(http.StatusForbidden, gin.H{"message": "access forbidden"})
|
|
||||||
c.Abort()
|
|
||||||
|
|
||||||
return nil, errors.New("failed cert authentication")
|
|
||||||
}
|
|
||||||
|
|
||||||
ret.machineID = fmt.Sprintf("%s@%s", extractedCN, c.ClientIP())
|
ret.machineID = fmt.Sprintf("%s@%s", extractedCN, c.ClientIP())
|
||||||
|
|
||||||
|
@ -88,14 +79,12 @@ func (j *JWT) authTLS(c *gin.Context) (*authInput, error) {
|
||||||
First(j.DbClient.CTX)
|
First(j.DbClient.CTX)
|
||||||
if ent.IsNotFound(err) {
|
if ent.IsNotFound(err) {
|
||||||
// Machine was not found, let's create it
|
// Machine was not found, let's create it
|
||||||
log.Infof("machine %s not found, create it", ret.machineID)
|
logger.Infof("machine %s not found, create it", ret.machineID)
|
||||||
// let's use an apikey as the password, doesn't matter in this case (generatePassword is only available in cscli)
|
// let's use an apikey as the password, doesn't matter in this case (generatePassword is only available in cscli)
|
||||||
pwd, err := GenerateAPIKey(dummyAPIKeySize)
|
pwd, err := GenerateAPIKey(dummyAPIKeySize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.WithFields(log.Fields{
|
logger.WithField("cn", extractedCN).
|
||||||
"ip": c.ClientIP(),
|
Errorf("error generating password: %s", err)
|
||||||
"cn": extractedCN,
|
|
||||||
}).Errorf("error generating password: %s", err)
|
|
||||||
|
|
||||||
return nil, errors.New("error generating password")
|
return nil, errors.New("error generating password")
|
||||||
}
|
}
|
||||||
|
|
100
pkg/apiserver/middlewares/v1/ocsp.go
Normal file
100
pkg/apiserver/middlewares/v1/ocsp.go
Normal file
|
@ -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
|
||||||
|
}
|
|
@ -1,79 +1,24 @@
|
||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"crypto"
|
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/pem"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"slices"
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"golang.org/x/crypto/ocsp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TLSAuth struct {
|
type TLSAuth struct {
|
||||||
AllowedOUs []string
|
AllowedOUs []string
|
||||||
CrlPath string
|
crlChecker *CRLChecker
|
||||||
revocationCache map[string]cacheEntry
|
ocspChecker *OCSPChecker
|
||||||
cacheExpiration time.Duration
|
revocationCache *RevocationCache
|
||||||
logger *log.Entry
|
logger *log.Entry
|
||||||
}
|
}
|
||||||
|
|
||||||
type cacheEntry struct {
|
|
||||||
revoked bool
|
|
||||||
timestamp time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ta *TLSAuth) ocspQuery(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 {
|
|
||||||
ta.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 {
|
|
||||||
ta.logger.Error("TLSAuth: cannot create HTTP request for OCSP")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ocspURL, err := url.Parse(server)
|
|
||||||
if err != nil {
|
|
||||||
ta.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{}
|
|
||||||
|
|
||||||
httpResponse, err := httpClient.Do(httpRequest)
|
|
||||||
if err != nil {
|
|
||||||
ta.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 {
|
|
||||||
ta.logger.Error("TLSAuth: cannot read HTTP response from OCSP")
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ocspResponse, err := ocsp.ParseResponseForCert(output, cert, issuer)
|
|
||||||
|
|
||||||
return ocspResponse, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ta *TLSAuth) isExpired(cert *x509.Certificate) bool {
|
func (ta *TLSAuth) isExpired(cert *x509.Certificate) bool {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
@ -90,211 +35,147 @@ func (ta *TLSAuth) isExpired(cert *x509.Certificate) bool {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// isOCSPRevoked checks if the client certificate is revoked by any of the OCSP servers present in the certificate.
|
// checkRevocationPath checks a single chain against OCSP and CRL
|
||||||
// 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 (ta *TLSAuth) checkRevocationPath(chain []*x509.Certificate) (error, bool) { //nolint:revive
|
||||||
func (ta *TLSAuth) isOCSPRevoked(cert *x509.Certificate, issuer *x509.Certificate) (bool, bool) {
|
// if we ever fail to check OCSP or CRL, we should not cache the result
|
||||||
if cert.OCSPServer == nil || len(cert.OCSPServer) == 0 {
|
couldCheck := true
|
||||||
ta.logger.Infof("TLSAuth: no OCSP Server present in client certificate, skipping OCSP verification")
|
|
||||||
return false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, server := range cert.OCSPServer {
|
// starting from the root CA and moving towards the leaf certificate,
|
||||||
ocspResponse, err := ta.ocspQuery(server, cert, issuer)
|
// check for revocation of intermediates too
|
||||||
if err != nil {
|
for i := len(chain) - 1; i > 0; i-- {
|
||||||
ta.logger.Errorf("TLSAuth: error querying OCSP server %s: %s", server, err)
|
cert := chain[i-1]
|
||||||
continue
|
issuer := chain[i]
|
||||||
|
|
||||||
|
revokedByOCSP, checkedByOCSP := ta.ocspChecker.isRevokedBy(cert, issuer)
|
||||||
|
couldCheck = couldCheck && checkedByOCSP
|
||||||
|
|
||||||
|
if revokedByOCSP && checkedByOCSP {
|
||||||
|
return errors.New("certificate revoked by OCSP"), couldCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ocspResponse.Status {
|
revokedByCRL, checkedByCRL := ta.crlChecker.isRevokedBy(cert, issuer)
|
||||||
case ocsp.Good:
|
couldCheck = couldCheck && checkedByCRL
|
||||||
return false, true
|
|
||||||
case ocsp.Revoked:
|
if revokedByCRL && checkedByCRL {
|
||||||
ta.logger.Errorf("TLSAuth: client certificate is revoked by server %s", server)
|
return errors.New("certificate revoked by CRL"), couldCheck
|
||||||
return true, true
|
|
||||||
case ocsp.Unknown:
|
|
||||||
log.Debugf("unknow OCSP status for server %s", server)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Could not get any valid OCSP response, assuming the cert is revoked")
|
return nil, couldCheck
|
||||||
|
|
||||||
return true, false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isCRLRevoked checks if the client certificate is revoked by the CRL present in the CrlPath.
|
func (ta *TLSAuth) setAllowedOu(allowedOus []string) error {
|
||||||
// It returns a boolean indicating if the certificate is revoked and a boolean indicating if the CRL check was successful and could be cached.
|
uniqueOUs := make(map[string]struct{})
|
||||||
func (ta *TLSAuth) isCRLRevoked(cert *x509.Certificate) (bool, bool) {
|
|
||||||
if ta.CrlPath == "" {
|
|
||||||
ta.logger.Info("no crl_path, skipping CRL check")
|
|
||||||
return false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
crlContent, err := os.ReadFile(ta.CrlPath)
|
|
||||||
if err != nil {
|
|
||||||
ta.logger.Errorf("could not read CRL file, skipping check: %s", err)
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var crlBlock *pem.Block
|
|
||||||
|
|
||||||
for {
|
|
||||||
crlBlock, crlContent = pem.Decode(crlContent)
|
|
||||||
if crlBlock == nil {
|
|
||||||
break // no more PEM blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
crl, err := x509.ParseRevocationList(crlBlock.Bytes)
|
|
||||||
if err != nil {
|
|
||||||
ta.logger.Errorf("could not parse a PEM block in CRL file, skipping: %s", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
if now.After(crl.NextUpdate) {
|
|
||||||
ta.logger.Warn("CRL has expired, will still validate the cert against it.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if now.Before(crl.ThisUpdate) {
|
|
||||||
ta.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 {
|
|
||||||
ta.logger.Warn("client certificate is revoked by CRL")
|
|
||||||
return true, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ta *TLSAuth) isRevoked(cert *x509.Certificate, issuer *x509.Certificate) (bool, error) {
|
|
||||||
sn := cert.SerialNumber.String()
|
|
||||||
if cacheValue, ok := ta.revocationCache[sn]; ok {
|
|
||||||
if time.Now().UTC().Sub(cacheValue.timestamp) < ta.cacheExpiration {
|
|
||||||
ta.logger.Debugf("TLSAuth: using cached value for cert %s: %t", sn, cacheValue.revoked)
|
|
||||||
return cacheValue.revoked, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ta.logger.Debugf("TLSAuth: cached value expired, removing from cache")
|
|
||||||
delete(ta.revocationCache, sn)
|
|
||||||
} else {
|
|
||||||
ta.logger.Tracef("TLSAuth: no cached value for cert %s", sn)
|
|
||||||
}
|
|
||||||
|
|
||||||
revokedByOCSP, cacheOCSP := ta.isOCSPRevoked(cert, issuer)
|
|
||||||
revokedByCRL, cacheCRL := ta.isCRLRevoked(cert)
|
|
||||||
revoked := revokedByOCSP || revokedByCRL
|
|
||||||
|
|
||||||
if cacheOCSP && cacheCRL {
|
|
||||||
ta.revocationCache[sn] = cacheEntry{
|
|
||||||
revoked: revoked,
|
|
||||||
timestamp: time.Now().UTC(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return revoked, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ta *TLSAuth) isInvalid(cert *x509.Certificate, issuer *x509.Certificate) (bool, error) {
|
|
||||||
if ta.isExpired(cert) {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
revoked, err := ta.isRevoked(cert, issuer)
|
|
||||||
if err != nil {
|
|
||||||
// Fail securely, if we can't check the revocation status, let's consider the cert invalid
|
|
||||||
// We may change this in the future based on users feedback, but this seems the most sensible thing to do
|
|
||||||
return true, fmt.Errorf("could not check for client certification revocation status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return revoked, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ta *TLSAuth) SetAllowedOu(allowedOus []string) error {
|
|
||||||
for _, ou := range allowedOus {
|
for _, ou := range allowedOus {
|
||||||
// disallow empty ou
|
// disallow empty ou
|
||||||
if ou == "" {
|
if ou == "" {
|
||||||
return errors.New("empty ou isn't allowed")
|
return errors.New("allowed_ou configuration contains invalid empty string")
|
||||||
}
|
}
|
||||||
|
|
||||||
// drop & warn on duplicate ou
|
if _, exists := uniqueOUs[ou]; exists {
|
||||||
ok := true
|
ta.logger.Warningf("dropping duplicate ou %s", ou)
|
||||||
|
continue
|
||||||
for _, validOu := range ta.AllowedOUs {
|
|
||||||
if validOu == ou {
|
|
||||||
ta.logger.Warningf("dropping duplicate ou %s", ou)
|
|
||||||
|
|
||||||
ok = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok {
|
uniqueOUs[ou] = struct{}{}
|
||||||
ta.AllowedOUs = append(ta.AllowedOUs, ou)
|
|
||||||
}
|
ta.AllowedOUs = append(ta.AllowedOUs, ou)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ta *TLSAuth) ValidateCert(c *gin.Context) (bool, string, error) {
|
func (ta *TLSAuth) checkAllowedOU(ous []string) error {
|
||||||
|
for _, ou := range ous {
|
||||||
|
if slices.Contains(ta.AllowedOUs, ou) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("client certificate OU %v doesn't match expected OU %v", ous, ta.AllowedOUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ta *TLSAuth) ValidateCert(c *gin.Context) (string, error) {
|
||||||
// Checks cert validity, Returns true + CN if client cert matches requested OU
|
// Checks cert validity, Returns true + CN if client cert matches requested OU
|
||||||
var clientCert *x509.Certificate
|
var leaf *x509.Certificate
|
||||||
|
|
||||||
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
||||||
// do not error if it's not TLS or there are no peer certs
|
return "", errors.New("no certificate in request")
|
||||||
return false, "", nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(c.Request.TLS.VerifiedChains) > 0 {
|
if len(c.Request.TLS.VerifiedChains) == 0 {
|
||||||
validOU := false
|
return "", errors.New("no verified cert in request")
|
||||||
clientCert = c.Request.TLS.VerifiedChains[0][0]
|
|
||||||
|
|
||||||
for _, ou := range clientCert.Subject.OrganizationalUnit {
|
|
||||||
for _, allowedOu := range ta.AllowedOUs {
|
|
||||||
if allowedOu == ou {
|
|
||||||
validOU = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !validOU {
|
|
||||||
return false, "", fmt.Errorf("client certificate OU (%v) doesn't match expected OU (%v)",
|
|
||||||
clientCert.Subject.OrganizationalUnit, ta.AllowedOUs)
|
|
||||||
}
|
|
||||||
|
|
||||||
revoked, err := ta.isInvalid(clientCert, c.Request.TLS.VerifiedChains[0][1])
|
|
||||||
if err != nil {
|
|
||||||
ta.logger.Errorf("TLSAuth: error checking if client certificate is revoked: %s", err)
|
|
||||||
return false, "", fmt.Errorf("could not check for client certification revocation status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if revoked {
|
|
||||||
return false, "", fmt.Errorf("client certificate for CN=%s OU=%s is revoked", clientCert.Subject.CommonName, clientCert.Subject.OrganizationalUnit)
|
|
||||||
}
|
|
||||||
|
|
||||||
ta.logger.Debugf("client OU %v is allowed vs required OU %v", clientCert.Subject.OrganizationalUnit, ta.AllowedOUs)
|
|
||||||
|
|
||||||
return true, clientCert.Subject.CommonName, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return false, "", errors.New("no verified cert in request")
|
// although there can be multiple chains, the leaf certificate is the same
|
||||||
|
// we take the first one
|
||||||
|
leaf = c.Request.TLS.VerifiedChains[0][0]
|
||||||
|
|
||||||
|
if err := ta.checkAllowedOU(leaf.Subject.OrganizationalUnit); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ta.isExpired(leaf) {
|
||||||
|
return "", errors.New("client certificate is expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
if validErr, cached := ta.revocationCache.Get(leaf); cached {
|
||||||
|
if validErr != nil {
|
||||||
|
return "", fmt.Errorf("(cache) %w", validErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return leaf.Subject.CommonName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
okToCache := true
|
||||||
|
|
||||||
|
var validErr error
|
||||||
|
|
||||||
|
var couldCheck bool
|
||||||
|
|
||||||
|
for _, chain := range c.Request.TLS.VerifiedChains {
|
||||||
|
validErr, couldCheck = ta.checkRevocationPath(chain)
|
||||||
|
okToCache = okToCache && couldCheck
|
||||||
|
|
||||||
|
if validErr != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if okToCache {
|
||||||
|
ta.revocationCache.Set(leaf, validErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if validErr != nil {
|
||||||
|
return "", validErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return leaf.Subject.CommonName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTLSAuth(allowedOus []string, crlPath string, cacheExpiration time.Duration, logger *log.Entry) (*TLSAuth, error) {
|
func NewTLSAuth(allowedOus []string, crlPath string, cacheExpiration time.Duration, logger *log.Entry) (*TLSAuth, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
cache := NewRevocationCache(cacheExpiration, logger)
|
||||||
|
|
||||||
ta := &TLSAuth{
|
ta := &TLSAuth{
|
||||||
revocationCache: map[string]cacheEntry{},
|
revocationCache: cache,
|
||||||
cacheExpiration: cacheExpiration,
|
ocspChecker: NewOCSPChecker(logger),
|
||||||
CrlPath: crlPath,
|
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := ta.SetAllowedOu(allowedOus)
|
switch crlPath {
|
||||||
if err != nil {
|
case "":
|
||||||
|
logger.Info("no crl_path, skipping CRL checks")
|
||||||
|
default:
|
||||||
|
ta.crlChecker, err = NewCRLChecker(crlPath, cache.Empty, logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ta.setAllowedOu(allowedOus); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,19 @@
|
||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
|
# root: root CA
|
||||||
|
# inter: intermediate CA
|
||||||
|
# inter_rev: intermediate CA revoked by root (CRL3)
|
||||||
|
# leaf: valid client cert
|
||||||
|
# leaf_rev1: client cert revoked by inter (CRL1)
|
||||||
|
# leaf_rev2: client cert revoked by inter (CRL2)
|
||||||
|
# leaf_rev3: client cert (indirectly) revoked by root
|
||||||
|
#
|
||||||
|
# CRL1: inter revokes leaf_rev1
|
||||||
|
# CRL2: inter revokes leaf_rev2
|
||||||
|
# CRL3: root revokes inter_rev
|
||||||
|
# CRL4: root revokes leaf, but is ignored
|
||||||
|
|
||||||
setup_file() {
|
setup_file() {
|
||||||
load "../lib/setup_file.sh"
|
load "../lib/setup_file.sh"
|
||||||
./instance-data load
|
./instance-data load
|
||||||
|
@ -10,43 +23,96 @@ setup_file() {
|
||||||
tmpdir="$BATS_FILE_TMPDIR"
|
tmpdir="$BATS_FILE_TMPDIR"
|
||||||
export tmpdir
|
export tmpdir
|
||||||
|
|
||||||
CFDIR="${BATS_TEST_DIRNAME}/testdata/cfssl"
|
CFDIR="$BATS_TEST_DIRNAME/testdata/cfssl"
|
||||||
export CFDIR
|
export CFDIR
|
||||||
|
|
||||||
# Generate the CA
|
# Root CA
|
||||||
cfssl gencert --initca "${CFDIR}/ca.json" 2>/dev/null | cfssljson --bare "${tmpdir}/ca"
|
cfssl gencert -loglevel 2 \
|
||||||
|
--initca "$CFDIR/ca_root.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/root"
|
||||||
|
|
||||||
# Generate an intermediate
|
# Intermediate CAs (valid or revoked)
|
||||||
cfssl gencert --initca "${CFDIR}/intermediate.json" 2>/dev/null | cfssljson --bare "${tmpdir}/inter"
|
for cert in "inter" "inter_rev"; do
|
||||||
cfssl sign -ca "${tmpdir}/ca.pem" -ca-key "${tmpdir}/ca-key.pem" -config "${CFDIR}/profiles.json" -profile intermediate_ca "${tmpdir}/inter.csr" 2>/dev/null | cfssljson --bare "${tmpdir}/inter"
|
cfssl gencert -loglevel 2 \
|
||||||
|
--initca "$CFDIR/ca_intermediate.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/$cert"
|
||||||
|
|
||||||
# Generate server cert for crowdsec with the intermediate
|
cfssl sign -loglevel 2 \
|
||||||
cfssl gencert -ca "${tmpdir}/inter.pem" -ca-key "${tmpdir}/inter-key.pem" -config "${CFDIR}/profiles.json" -profile=server "${CFDIR}/server.json" 2>/dev/null | cfssljson --bare "${tmpdir}/server"
|
-ca "$tmpdir/root.pem" -ca-key "$tmpdir/root-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile intermediate_ca "$tmpdir/$cert.csr" \
|
||||||
# Generate client cert for the bouncer
|
| cfssljson --bare "$tmpdir/$cert"
|
||||||
cfssl gencert -ca "${tmpdir}/inter.pem" -ca-key "${tmpdir}/inter-key.pem" -config "${CFDIR}/profiles.json" -profile=client "${CFDIR}/bouncer.json" 2>/dev/null | cfssljson --bare "${tmpdir}/bouncer"
|
|
||||||
|
|
||||||
# Genearte client cert for the bouncer with an invalid OU
|
|
||||||
cfssl gencert -ca "${tmpdir}/inter.pem" -ca-key "${tmpdir}/inter-key.pem" -config "${CFDIR}/profiles.json" -profile=client "${CFDIR}/bouncer_invalid.json" 2>/dev/null | cfssljson --bare "${tmpdir}/bouncer_bad_ou"
|
|
||||||
|
|
||||||
# Generate client cert for the bouncer directly signed by the CA, it should be refused by crowdsec as uses the intermediate
|
|
||||||
cfssl gencert -ca "${tmpdir}/ca.pem" -ca-key "${tmpdir}/ca-key.pem" -config "${CFDIR}/profiles.json" -profile=client "${CFDIR}/bouncer.json" 2>/dev/null | cfssljson --bare "${tmpdir}/bouncer_invalid"
|
|
||||||
|
|
||||||
# Generate revoked client certs
|
|
||||||
for cert_name in "revoked_1" "revoked_2"; do
|
|
||||||
cfssl gencert -ca "${tmpdir}/inter.pem" -ca-key "${tmpdir}/inter-key.pem" -config "${CFDIR}/profiles.json" -profile=client "${CFDIR}/bouncer.json" 2>/dev/null | cfssljson --bare "${tmpdir}/${cert_name}"
|
|
||||||
cfssl certinfo -cert "${tmpdir}/${cert_name}.pem" | jq -r '.serial_number' > "${tmpdir}/serials_${cert_name}.txt"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# Generate separate CRL blocks and concatenate them
|
# Server cert for crowdsec with the intermediate
|
||||||
for cert_name in "revoked_1" "revoked_2"; do
|
cfssl gencert -loglevel 2 \
|
||||||
echo '-----BEGIN X509 CRL-----' > "${tmpdir}/crl_${cert_name}.pem"
|
-ca "$tmpdir/inter.pem" -ca-key "$tmpdir/inter-key.pem" \
|
||||||
cfssl gencrl "${tmpdir}/serials_${cert_name}.txt" "${tmpdir}/ca.pem" "${tmpdir}/ca-key.pem" >> "${tmpdir}/crl_${cert_name}.pem"
|
-config "$CFDIR/profiles.json" -profile=server "$CFDIR/server.json" \
|
||||||
echo '-----END X509 CRL-----' >> "${tmpdir}/crl_${cert_name}.pem"
|
| cfssljson --bare "$tmpdir/server"
|
||||||
done
|
|
||||||
cat "${tmpdir}/crl_revoked_1.pem" "${tmpdir}/crl_revoked_2.pem" >"${tmpdir}/crl.pem"
|
|
||||||
|
|
||||||
cat "${tmpdir}/ca.pem" "${tmpdir}/inter.pem" > "${tmpdir}/bundle.pem"
|
# Client certs (valid or revoked)
|
||||||
|
for cert in "leaf" "leaf_rev1" "leaf_rev2"; do
|
||||||
|
cfssl gencert -loglevel 3 \
|
||||||
|
-ca "$tmpdir/inter.pem" -ca-key "$tmpdir/inter-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile=client \
|
||||||
|
"$CFDIR/bouncer.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/$cert"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Client cert (by revoked inter)
|
||||||
|
cfssl gencert -loglevel 3 \
|
||||||
|
-ca "$tmpdir/inter_rev.pem" -ca-key "$tmpdir/inter_rev-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile=client \
|
||||||
|
"$CFDIR/bouncer.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/leaf_rev3"
|
||||||
|
|
||||||
|
# Bad client cert (invalid OU)
|
||||||
|
cfssl gencert -loglevel 3 \
|
||||||
|
-ca "$tmpdir/inter.pem" -ca-key "$tmpdir/inter-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile=client \
|
||||||
|
"$CFDIR/bouncer_invalid.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/leaf_bad_ou"
|
||||||
|
|
||||||
|
# Bad client cert (directly signed by the CA, it should be refused by crowdsec as it uses the intermediate)
|
||||||
|
cfssl gencert -loglevel 3 \
|
||||||
|
-ca "$tmpdir/root.pem" -ca-key "$tmpdir/root-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile=client \
|
||||||
|
"$CFDIR/bouncer.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/leaf_invalid"
|
||||||
|
|
||||||
|
truncate -s 0 "$tmpdir/crl.pem"
|
||||||
|
|
||||||
|
# Revoke certs
|
||||||
|
{
|
||||||
|
echo '-----BEGIN X509 CRL-----'
|
||||||
|
cfssl gencrl \
|
||||||
|
<(cert_serial_number "$tmpdir/leaf_rev1.pem") \
|
||||||
|
"$tmpdir/inter.pem" \
|
||||||
|
"$tmpdir/inter-key.pem"
|
||||||
|
echo '-----END X509 CRL-----'
|
||||||
|
|
||||||
|
echo '-----BEGIN X509 CRL-----'
|
||||||
|
cfssl gencrl \
|
||||||
|
<(cert_serial_number "$tmpdir/leaf_rev2.pem") \
|
||||||
|
"$tmpdir/inter.pem" \
|
||||||
|
"$tmpdir/inter-key.pem"
|
||||||
|
echo '-----END X509 CRL-----'
|
||||||
|
|
||||||
|
echo '-----BEGIN X509 CRL-----'
|
||||||
|
cfssl gencrl \
|
||||||
|
<(cert_serial_number "$tmpdir/inter_rev.pem") \
|
||||||
|
"$tmpdir/root.pem" \
|
||||||
|
"$tmpdir/root-key.pem"
|
||||||
|
echo '-----END X509 CRL-----'
|
||||||
|
|
||||||
|
echo '-----BEGIN X509 CRL-----'
|
||||||
|
cfssl gencrl \
|
||||||
|
<(cert_serial_number "$tmpdir/leaf.pem") \
|
||||||
|
"$tmpdir/root.pem" \
|
||||||
|
"$tmpdir/root-key.pem"
|
||||||
|
echo '-----END X509 CRL-----'
|
||||||
|
} >> "$tmpdir/crl.pem"
|
||||||
|
|
||||||
|
cat "$tmpdir/root.pem" "$tmpdir/inter.pem" > "$tmpdir/bundle.pem"
|
||||||
|
|
||||||
config_set '
|
config_set '
|
||||||
.api.server.tls.cert_file=strenv(tmpdir) + "/server.pem" |
|
.api.server.tls.cert_file=strenv(tmpdir) + "/server.pem" |
|
||||||
|
@ -79,8 +145,12 @@ teardown() {
|
||||||
assert_output "[]"
|
assert_output "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "simulate one bouncer request with a valid cert" {
|
@test "simulate a bouncer request with a valid cert" {
|
||||||
rune -0 curl -s --cert "${tmpdir}/bouncer.pem" --key "${tmpdir}/bouncer-key.pem" --cacert "${tmpdir}/bundle.pem" https://localhost:8080/v1/decisions\?ip=42.42.42.42
|
rune -0 curl -f -s \
|
||||||
|
--cert "$tmpdir/leaf.pem" \
|
||||||
|
--key "$tmpdir/leaf-key.pem" \
|
||||||
|
--cacert "$tmpdir/bundle.pem" \
|
||||||
|
https://localhost:8080/v1/decisions\?ip=42.42.42.42
|
||||||
assert_output "null"
|
assert_output "null"
|
||||||
rune -0 cscli bouncers list -o json
|
rune -0 cscli bouncers list -o json
|
||||||
rune -0 jq '. | length' <(output)
|
rune -0 jq '. | length' <(output)
|
||||||
|
@ -91,27 +161,54 @@ teardown() {
|
||||||
rune cscli bouncers delete localhost@127.0.0.1
|
rune cscli bouncers delete localhost@127.0.0.1
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "simulate one bouncer request with an invalid cert" {
|
@test "simulate a bouncer request with an invalid cert" {
|
||||||
rune curl -s --cert "${tmpdir}/bouncer_invalid.pem" --key "${tmpdir}/bouncer_invalid-key.pem" --cacert "${tmpdir}/ca-key.pem" https://localhost:8080/v1/decisions\?ip=42.42.42.42
|
rune -77 curl -f -s \
|
||||||
|
--cert "$tmpdir/leaf_invalid.pem" \
|
||||||
|
--key "$tmpdir/leaf_invalid-key.pem" \
|
||||||
|
--cacert "$tmpdir/root-key.pem" \
|
||||||
|
https://localhost:8080/v1/decisions\?ip=42.42.42.42
|
||||||
rune -0 cscli bouncers list -o json
|
rune -0 cscli bouncers list -o json
|
||||||
assert_output "[]"
|
assert_output "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "simulate one bouncer request with an invalid OU" {
|
@test "simulate a bouncer request with an invalid OU" {
|
||||||
rune curl -s --cert "${tmpdir}/bouncer_bad_ou.pem" --key "${tmpdir}/bouncer_bad_ou-key.pem" --cacert "${tmpdir}/bundle.pem" https://localhost:8080/v1/decisions\?ip=42.42.42.42
|
rune -0 curl -s \
|
||||||
|
--cert "$tmpdir/leaf_bad_ou.pem" \
|
||||||
|
--key "$tmpdir/leaf_bad_ou-key.pem" \
|
||||||
|
--cacert "$tmpdir/bundle.pem" \
|
||||||
|
https://localhost:8080/v1/decisions\?ip=42.42.42.42
|
||||||
|
assert_json '{message:"access forbidden"}'
|
||||||
rune -0 cscli bouncers list -o json
|
rune -0 cscli bouncers list -o json
|
||||||
assert_output "[]"
|
assert_output "[]"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "simulate one bouncer request with a revoked certificate" {
|
@test "simulate a bouncer request with a revoked certificate" {
|
||||||
# we have two certificates revoked by different CRL blocks
|
# we have two certificates revoked by different CRL blocks
|
||||||
for cert_name in "revoked_1" "revoked_2"; do
|
# we connect twice to test the cache too
|
||||||
|
for cert in "leaf_rev1" "leaf_rev2" "leaf_rev1" "leaf_rev2"; do
|
||||||
truncate_log
|
truncate_log
|
||||||
rune -0 curl -i -s --cert "${tmpdir}/${cert_name}.pem" --key "${tmpdir}/${cert_name}-key.pem" --cacert "${tmpdir}/bundle.pem" https://localhost:8080/v1/decisions\?ip=42.42.42.42
|
rune -0 curl -s \
|
||||||
assert_log --partial "client certificate is revoked by CRL"
|
--cert "$tmpdir/$cert.pem" \
|
||||||
assert_log --partial "client certificate for CN=localhost OU=[bouncer-ou] is revoked"
|
--key "$tmpdir/$cert-key.pem" \
|
||||||
|
--cacert "$tmpdir/bundle.pem" \
|
||||||
|
https://localhost:8080/v1/decisions\?ip=42.42.42.42
|
||||||
|
assert_log --partial "certificate revoked by CRL"
|
||||||
assert_output --partial "access forbidden"
|
assert_output --partial "access forbidden"
|
||||||
rune -0 cscli bouncers list -o json
|
rune -0 cscli bouncers list -o json
|
||||||
assert_output "[]"
|
assert_output "[]"
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# vvv this test must be last, or it can break the ones that follow
|
||||||
|
|
||||||
|
@test "allowed_ou can't contain an empty string" {
|
||||||
|
./instance-crowdsec stop
|
||||||
|
config_set '
|
||||||
|
.common.log_media="stdout" |
|
||||||
|
.api.server.tls.bouncers_allowed_ou=["bouncer-ou", ""]
|
||||||
|
'
|
||||||
|
rune -1 wait-for "$CROWDSEC"
|
||||||
|
assert_stderr --partial "allowed_ou configuration contains invalid empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ^^^ this test must be last, or it can break the ones that follow
|
||||||
|
|
|
@ -3,6 +3,20 @@
|
||||||
|
|
||||||
set -u
|
set -u
|
||||||
|
|
||||||
|
|
||||||
|
# root: root CA
|
||||||
|
# inter: intermediate CA
|
||||||
|
# inter_rev: intermediate CA revoked by root (CRL3)
|
||||||
|
# leaf: valid client cert
|
||||||
|
# leaf_rev1: client cert revoked by inter (CRL1)
|
||||||
|
# leaf_rev2: client cert revoked by inter (CRL2)
|
||||||
|
# leaf_rev3: client cert (indirectly) revoked by root
|
||||||
|
#
|
||||||
|
# CRL1: inter revokes leaf_rev1
|
||||||
|
# CRL2: inter revokes leaf_rev2
|
||||||
|
# CRL3: root revokes inter_rev
|
||||||
|
# CRL4: root revokes leaf, but is ignored
|
||||||
|
|
||||||
setup_file() {
|
setup_file() {
|
||||||
load "../lib/setup_file.sh"
|
load "../lib/setup_file.sh"
|
||||||
./instance-data load
|
./instance-data load
|
||||||
|
@ -13,43 +27,96 @@ setup_file() {
|
||||||
tmpdir="$BATS_FILE_TMPDIR"
|
tmpdir="$BATS_FILE_TMPDIR"
|
||||||
export tmpdir
|
export tmpdir
|
||||||
|
|
||||||
CFDIR="${BATS_TEST_DIRNAME}/testdata/cfssl"
|
CFDIR="$BATS_TEST_DIRNAME/testdata/cfssl"
|
||||||
export CFDIR
|
export CFDIR
|
||||||
|
|
||||||
# Generate the CA
|
# Root CA
|
||||||
cfssl gencert --initca "${CFDIR}/ca.json" 2>/dev/null | cfssljson --bare "${tmpdir}/ca"
|
cfssl gencert -loglevel 2 \
|
||||||
|
--initca "$CFDIR/ca_root.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/root"
|
||||||
|
|
||||||
# Generate an intermediate
|
# Intermediate CAs (valid or revoked)
|
||||||
cfssl gencert --initca "${CFDIR}/intermediate.json" 2>/dev/null | cfssljson --bare "${tmpdir}/inter"
|
for cert in "inter" "inter_rev"; do
|
||||||
cfssl sign -ca "${tmpdir}/ca.pem" -ca-key "${tmpdir}/ca-key.pem" -config "${CFDIR}/profiles.json" -profile intermediate_ca "${tmpdir}/inter.csr" 2>/dev/null | cfssljson --bare "${tmpdir}/inter"
|
cfssl gencert -loglevel 2 \
|
||||||
|
--initca "$CFDIR/ca_intermediate.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/$cert"
|
||||||
|
|
||||||
# Generate server cert for crowdsec with the intermediate
|
cfssl sign -loglevel 2 \
|
||||||
cfssl gencert -ca "${tmpdir}/inter.pem" -ca-key "${tmpdir}/inter-key.pem" -config "${CFDIR}/profiles.json" -profile=server "${CFDIR}/server.json" 2>/dev/null | cfssljson --bare "${tmpdir}/server"
|
-ca "$tmpdir/root.pem" -ca-key "$tmpdir/root-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile intermediate_ca "$tmpdir/$cert.csr" \
|
||||||
# Generate client cert for the agent
|
| cfssljson --bare "$tmpdir/$cert"
|
||||||
cfssl gencert -ca "${tmpdir}/inter.pem" -ca-key "${tmpdir}/inter-key.pem" -config "${CFDIR}/profiles.json" -profile=client "${CFDIR}/agent.json" 2>/dev/null | cfssljson --bare "${tmpdir}/agent"
|
|
||||||
|
|
||||||
# Genearte client cert for the agent with an invalid OU
|
|
||||||
cfssl gencert -ca "${tmpdir}/inter.pem" -ca-key "${tmpdir}/inter-key.pem" -config "${CFDIR}/profiles.json" -profile=client "${CFDIR}/agent_invalid.json" 2>/dev/null | cfssljson --bare "${tmpdir}/agent_bad_ou"
|
|
||||||
|
|
||||||
# Generate client cert for the bouncer directly signed by the CA, it should be refused by crowdsec as uses the intermediate
|
|
||||||
cfssl gencert -ca "${tmpdir}/ca.pem" -ca-key "${tmpdir}/ca-key.pem" -config "${CFDIR}/profiles.json" -profile=client "${CFDIR}/agent.json" 2>/dev/null | cfssljson --bare "${tmpdir}/agent_invalid"
|
|
||||||
|
|
||||||
# Generate revoked client cert
|
|
||||||
for cert_name in "revoked_1" "revoked_2"; do
|
|
||||||
cfssl gencert -ca "${tmpdir}/inter.pem" -ca-key "${tmpdir}/inter-key.pem" -config "${CFDIR}/profiles.json" -profile=client "${CFDIR}/agent.json" 2>/dev/null | cfssljson --bare "${tmpdir}/${cert_name}"
|
|
||||||
cfssl certinfo -cert "${tmpdir}/${cert_name}.pem" | jq -r '.serial_number' > "${tmpdir}/serials_${cert_name}.txt"
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# Generate separate CRL blocks and concatenate them
|
# Server cert for crowdsec with the intermediate
|
||||||
for cert_name in "revoked_1" "revoked_2"; do
|
cfssl gencert -loglevel 2 \
|
||||||
echo '-----BEGIN X509 CRL-----' > "${tmpdir}/crl_${cert_name}.pem"
|
-ca "$tmpdir/inter.pem" -ca-key "$tmpdir/inter-key.pem" \
|
||||||
cfssl gencrl "${tmpdir}/serials_${cert_name}.txt" "${tmpdir}/ca.pem" "${tmpdir}/ca-key.pem" >> "${tmpdir}/crl_${cert_name}.pem"
|
-config "$CFDIR/profiles.json" -profile=server "$CFDIR/server.json" \
|
||||||
echo '-----END X509 CRL-----' >> "${tmpdir}/crl_${cert_name}.pem"
|
| cfssljson --bare "$tmpdir/server"
|
||||||
done
|
|
||||||
cat "${tmpdir}/crl_revoked_1.pem" "${tmpdir}/crl_revoked_2.pem" >"${tmpdir}/crl.pem"
|
|
||||||
|
|
||||||
cat "${tmpdir}/ca.pem" "${tmpdir}/inter.pem" > "${tmpdir}/bundle.pem"
|
# Client certs (valid or revoked)
|
||||||
|
for cert in "leaf" "leaf_rev1" "leaf_rev2"; do
|
||||||
|
cfssl gencert -loglevel 3 \
|
||||||
|
-ca "$tmpdir/inter.pem" -ca-key "$tmpdir/inter-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile=client \
|
||||||
|
"$CFDIR/agent.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/$cert"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Client cert (by revoked inter)
|
||||||
|
cfssl gencert -loglevel 3 \
|
||||||
|
-ca "$tmpdir/inter_rev.pem" -ca-key "$tmpdir/inter_rev-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile=client \
|
||||||
|
"$CFDIR/agent.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/leaf_rev3"
|
||||||
|
|
||||||
|
# Bad client cert (invalid OU)
|
||||||
|
cfssl gencert -loglevel 3 \
|
||||||
|
-ca "$tmpdir/inter.pem" -ca-key "$tmpdir/inter-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile=client \
|
||||||
|
"$CFDIR/agent_invalid.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/leaf_bad_ou"
|
||||||
|
|
||||||
|
# Bad client cert (directly signed by the CA, it should be refused by crowdsec as it uses the intermediate)
|
||||||
|
cfssl gencert -loglevel 3 \
|
||||||
|
-ca "$tmpdir/root.pem" -ca-key "$tmpdir/root-key.pem" \
|
||||||
|
-config "$CFDIR/profiles.json" -profile=client \
|
||||||
|
"$CFDIR/agent.json" \
|
||||||
|
| cfssljson --bare "$tmpdir/leaf_invalid"
|
||||||
|
|
||||||
|
truncate -s 0 "$tmpdir/crl.pem"
|
||||||
|
|
||||||
|
# Revoke certs
|
||||||
|
{
|
||||||
|
echo '-----BEGIN X509 CRL-----'
|
||||||
|
cfssl gencrl \
|
||||||
|
<(cert_serial_number "$tmpdir/leaf_rev1.pem") \
|
||||||
|
"$tmpdir/inter.pem" \
|
||||||
|
"$tmpdir/inter-key.pem"
|
||||||
|
echo '-----END X509 CRL-----'
|
||||||
|
|
||||||
|
echo '-----BEGIN X509 CRL-----'
|
||||||
|
cfssl gencrl \
|
||||||
|
<(cert_serial_number "$tmpdir/leaf_rev2.pem") \
|
||||||
|
"$tmpdir/inter.pem" \
|
||||||
|
"$tmpdir/inter-key.pem"
|
||||||
|
echo '-----END X509 CRL-----'
|
||||||
|
|
||||||
|
echo '-----BEGIN X509 CRL-----'
|
||||||
|
cfssl gencrl \
|
||||||
|
<(cert_serial_number "$tmpdir/inter_rev.pem") \
|
||||||
|
"$tmpdir/root.pem" \
|
||||||
|
"$tmpdir/root-key.pem"
|
||||||
|
echo '-----END X509 CRL-----'
|
||||||
|
|
||||||
|
echo '-----BEGIN X509 CRL-----'
|
||||||
|
cfssl gencrl \
|
||||||
|
<(cert_serial_number "$tmpdir/leaf.pem") \
|
||||||
|
"$tmpdir/root.pem" \
|
||||||
|
"$tmpdir/root-key.pem"
|
||||||
|
echo '-----END X509 CRL-----'
|
||||||
|
} >> "$tmpdir/crl.pem"
|
||||||
|
|
||||||
|
cat "$tmpdir/root.pem" "$tmpdir/inter.pem" > "$tmpdir/bundle.pem"
|
||||||
|
|
||||||
config_set '
|
config_set '
|
||||||
.api.server.tls.cert_file=strenv(tmpdir) + "/server.pem" |
|
.api.server.tls.cert_file=strenv(tmpdir) + "/server.pem" |
|
||||||
|
@ -62,7 +129,7 @@ setup_file() {
|
||||||
# remove all machines
|
# remove all machines
|
||||||
|
|
||||||
for machine in $(cscli machines list -o json | jq -r '.[].machineId'); do
|
for machine in $(cscli machines list -o json | jq -r '.[].machineId'); do
|
||||||
cscli machines delete "${machine}" >/dev/null 2>&1
|
cscli machines delete "$machine" >/dev/null 2>&1
|
||||||
done
|
done
|
||||||
|
|
||||||
config_disable_agent
|
config_disable_agent
|
||||||
|
@ -106,30 +173,32 @@ teardown() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "invalid OU for agent" {
|
@test "invalid OU for agent" {
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" '
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" '
|
||||||
.ca_cert_path=strenv(tmpdir) + "/bundle.pem" |
|
.ca_cert_path=strenv(tmpdir) + "/bundle.pem" |
|
||||||
.key_path=strenv(tmpdir) + "/agent_bad_ou-key.pem" |
|
.key_path=strenv(tmpdir) + "/leaf_bad_ou-key.pem" |
|
||||||
.cert_path=strenv(tmpdir) + "/agent_bad_ou.pem" |
|
.cert_path=strenv(tmpdir) + "/leaf_bad_ou.pem" |
|
||||||
.url="https://127.0.0.1:8080"
|
.url="https://127.0.0.1:8080"
|
||||||
'
|
'
|
||||||
|
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" 'del(.login,.password)'
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" 'del(.login,.password)'
|
||||||
./instance-crowdsec start
|
./instance-crowdsec start
|
||||||
rune -0 cscli machines list -o json
|
rune -0 cscli machines list -o json
|
||||||
assert_output '[]'
|
assert_output '[]'
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "we have exactly one machine registered with TLS" {
|
@test "we have exactly one machine registered with TLS" {
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" '
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" '
|
||||||
.ca_cert_path=strenv(tmpdir) + "/bundle.pem" |
|
.ca_cert_path=strenv(tmpdir) + "/bundle.pem" |
|
||||||
.key_path=strenv(tmpdir) + "/agent-key.pem" |
|
.key_path=strenv(tmpdir) + "/leaf-key.pem" |
|
||||||
.cert_path=strenv(tmpdir) + "/agent.pem" |
|
.cert_path=strenv(tmpdir) + "/leaf.pem" |
|
||||||
.url="https://127.0.0.1:8080"
|
.url="https://127.0.0.1:8080"
|
||||||
'
|
'
|
||||||
|
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" 'del(.login,.password)'
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" 'del(.login,.password)'
|
||||||
./instance-crowdsec start
|
./instance-crowdsec start
|
||||||
rune -0 cscli lapi status
|
rune -0 cscli lapi status
|
||||||
|
# second connection, test the tls cache
|
||||||
|
rune -0 cscli lapi status
|
||||||
rune -0 cscli machines list -o json
|
rune -0 cscli machines list -o json
|
||||||
rune -0 jq -c '[. | length, .[0].machineId[0:32], .[0].isValidated, .[0].ipAddress, .[0].auth_type]' <(output)
|
rune -0 jq -c '[. | length, .[0].machineId[0:32], .[0].isValidated, .[0].ipAddress, .[0].auth_type]' <(output)
|
||||||
|
|
||||||
|
@ -154,24 +223,24 @@ teardown() {
|
||||||
|
|
||||||
# TLS cannot be used with a unix socket
|
# TLS cannot be used with a unix socket
|
||||||
|
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" '
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" '
|
||||||
.ca_cert_path=strenv(tmpdir) + "/bundle.pem"
|
.ca_cert_path=strenv(tmpdir) + "/bundle.pem"
|
||||||
'
|
'
|
||||||
|
|
||||||
rune -1 cscli lapi status
|
rune -1 cscli lapi status
|
||||||
assert_stderr --partial "loading api client: cannot use TLS with a unix socket"
|
assert_stderr --partial "loading api client: cannot use TLS with a unix socket"
|
||||||
|
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" '
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" '
|
||||||
del(.ca_cert_path) |
|
del(.ca_cert_path) |
|
||||||
.key_path=strenv(tmpdir) + "/agent-key.pem"
|
.key_path=strenv(tmpdir) + "/leaf-key.pem"
|
||||||
'
|
'
|
||||||
|
|
||||||
rune -1 cscli lapi status
|
rune -1 cscli lapi status
|
||||||
assert_stderr --partial "loading api client: cannot use TLS with a unix socket"
|
assert_stderr --partial "loading api client: cannot use TLS with a unix socket"
|
||||||
|
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" '
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" '
|
||||||
del(.key_path) |
|
del(.key_path) |
|
||||||
.cert_path=strenv(tmpdir) + "/agent.pem"
|
.cert_path=strenv(tmpdir) + "/leaf.pem"
|
||||||
'
|
'
|
||||||
|
|
||||||
rune -1 cscli lapi status
|
rune -1 cscli lapi status
|
||||||
|
@ -181,13 +250,13 @@ teardown() {
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "invalid cert for agent" {
|
@test "invalid cert for agent" {
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" '
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" '
|
||||||
.ca_cert_path=strenv(tmpdir) + "/bundle.pem" |
|
.ca_cert_path=strenv(tmpdir) + "/bundle.pem" |
|
||||||
.key_path=strenv(tmpdir) + "/agent_invalid-key.pem" |
|
.key_path=strenv(tmpdir) + "/leaf_invalid-key.pem" |
|
||||||
.cert_path=strenv(tmpdir) + "/agent_invalid.pem" |
|
.cert_path=strenv(tmpdir) + "/leaf_invalid.pem" |
|
||||||
.url="https://127.0.0.1:8080"
|
.url="https://127.0.0.1:8080"
|
||||||
'
|
'
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" 'del(.login,.password)'
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" 'del(.login,.password)'
|
||||||
./instance-crowdsec start
|
./instance-crowdsec start
|
||||||
rune -1 cscli lapi status
|
rune -1 cscli lapi status
|
||||||
rune -0 cscli machines list -o json
|
rune -0 cscli machines list -o json
|
||||||
|
@ -196,22 +265,35 @@ teardown() {
|
||||||
|
|
||||||
@test "revoked cert for agent" {
|
@test "revoked cert for agent" {
|
||||||
# we have two certificates revoked by different CRL blocks
|
# we have two certificates revoked by different CRL blocks
|
||||||
for cert_name in "revoked_1" "revoked_2"; do
|
# we connect twice to test the cache too
|
||||||
|
for cert in "leaf_rev1" "leaf_rev2" "leaf_rev1" "leaf_rev2"; do
|
||||||
truncate_log
|
truncate_log
|
||||||
cert_name="$cert_name" config_set "${CONFIG_DIR}/local_api_credentials.yaml" '
|
cert="$cert" config_set "$CONFIG_DIR/local_api_credentials.yaml" '
|
||||||
.ca_cert_path=strenv(tmpdir) + "/bundle.pem" |
|
.ca_cert_path=strenv(tmpdir) + "/bundle.pem" |
|
||||||
.key_path=strenv(tmpdir) + "/" + strenv(cert_name) + "-key.pem" |
|
.key_path=strenv(tmpdir) + "/" + strenv(cert) + "-key.pem" |
|
||||||
.cert_path=strenv(tmpdir) + "/" + strenv(cert_name) + ".pem" |
|
.cert_path=strenv(tmpdir) + "/" + strenv(cert) + ".pem" |
|
||||||
.url="https://127.0.0.1:8080"
|
.url="https://127.0.0.1:8080"
|
||||||
'
|
'
|
||||||
|
|
||||||
config_set "${CONFIG_DIR}/local_api_credentials.yaml" 'del(.login,.password)'
|
config_set "$CONFIG_DIR/local_api_credentials.yaml" 'del(.login,.password)'
|
||||||
./instance-crowdsec start
|
./instance-crowdsec start
|
||||||
rune -1 cscli lapi status
|
rune -1 cscli lapi status
|
||||||
assert_log --partial "client certificate is revoked by CRL"
|
assert_log --partial "certificate revoked by CRL"
|
||||||
assert_log --partial "client certificate for CN=localhost OU=[agent-ou] is revoked"
|
|
||||||
rune -0 cscli machines list -o json
|
rune -0 cscli machines list -o json
|
||||||
assert_output '[]'
|
assert_output '[]'
|
||||||
./instance-crowdsec stop
|
./instance-crowdsec stop
|
||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# vvv this test must be last, or it can break the ones that follow
|
||||||
|
|
||||||
|
@test "allowed_ou can't contain an empty string" {
|
||||||
|
config_set '
|
||||||
|
.common.log_media="stdout" |
|
||||||
|
.api.server.tls.agents_allowed_ou=["agent-ou", ""]
|
||||||
|
'
|
||||||
|
rune -1 wait-for "$CROWDSEC"
|
||||||
|
assert_stderr --partial "allowed_ou configuration contains invalid empty string"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ^^^ this test must be last, or it can break the ones that follow
|
||||||
|
|
16
test/bats/testdata/cfssl/agent.json
vendored
16
test/bats/testdata/cfssl/agent.json
vendored
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"CN": "localhost",
|
"CN": "localhost",
|
||||||
"key": {
|
"key": {
|
||||||
"algo": "rsa",
|
"algo": "rsa",
|
||||||
"size": 2048
|
"size": 2048
|
||||||
},
|
},
|
||||||
"names": [
|
"names": [
|
||||||
{
|
{
|
||||||
"C": "FR",
|
"C": "FR",
|
||||||
"L": "Paris",
|
"L": "Paris",
|
||||||
|
@ -12,5 +12,5 @@
|
||||||
"OU": "agent-ou",
|
"OU": "agent-ou",
|
||||||
"ST": "France"
|
"ST": "France"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
16
test/bats/testdata/cfssl/agent_invalid.json
vendored
16
test/bats/testdata/cfssl/agent_invalid.json
vendored
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"CN": "localhost",
|
"CN": "localhost",
|
||||||
"key": {
|
"key": {
|
||||||
"algo": "rsa",
|
"algo": "rsa",
|
||||||
"size": 2048
|
"size": 2048
|
||||||
},
|
},
|
||||||
"names": [
|
"names": [
|
||||||
{
|
{
|
||||||
"C": "FR",
|
"C": "FR",
|
||||||
"L": "Paris",
|
"L": "Paris",
|
||||||
|
@ -12,5 +12,5 @@
|
||||||
"OU": "this-is-not-the-ou-youre-looking-for",
|
"OU": "this-is-not-the-ou-youre-looking-for",
|
||||||
"ST": "France"
|
"ST": "France"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
16
test/bats/testdata/cfssl/bouncer.json
vendored
16
test/bats/testdata/cfssl/bouncer.json
vendored
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"CN": "localhost",
|
"CN": "localhost",
|
||||||
"key": {
|
"key": {
|
||||||
"algo": "rsa",
|
"algo": "rsa",
|
||||||
"size": 2048
|
"size": 2048
|
||||||
},
|
},
|
||||||
"names": [
|
"names": [
|
||||||
{
|
{
|
||||||
"C": "FR",
|
"C": "FR",
|
||||||
"L": "Paris",
|
"L": "Paris",
|
||||||
|
@ -12,5 +12,5 @@
|
||||||
"OU": "bouncer-ou",
|
"OU": "bouncer-ou",
|
||||||
"ST": "France"
|
"ST": "France"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
16
test/bats/testdata/cfssl/bouncer_invalid.json
vendored
16
test/bats/testdata/cfssl/bouncer_invalid.json
vendored
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"CN": "localhost",
|
"CN": "localhost",
|
||||||
"key": {
|
"key": {
|
||||||
"algo": "rsa",
|
"algo": "rsa",
|
||||||
"size": 2048
|
"size": 2048
|
||||||
},
|
},
|
||||||
"names": [
|
"names": [
|
||||||
{
|
{
|
||||||
"C": "FR",
|
"C": "FR",
|
||||||
"L": "Paris",
|
"L": "Paris",
|
||||||
|
@ -12,5 +12,5 @@
|
||||||
"OU": "this-is-not-the-ou-youre-looking-for",
|
"OU": "this-is-not-the-ou-youre-looking-for",
|
||||||
"ST": "France"
|
"ST": "France"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
16
test/bats/testdata/cfssl/ca.json
vendored
16
test/bats/testdata/cfssl/ca.json
vendored
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"CN": "CrowdSec Test CA",
|
|
||||||
"key": {
|
|
||||||
"algo": "rsa",
|
|
||||||
"size": 2048
|
|
||||||
},
|
|
||||||
"names": [
|
|
||||||
{
|
|
||||||
"C": "FR",
|
|
||||||
"L": "Paris",
|
|
||||||
"O": "Crowdsec",
|
|
||||||
"OU": "Crowdsec",
|
|
||||||
"ST": "France"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"CN": "CrowdSec Test CA Intermediate",
|
"CN": "CrowdSec Test CA Intermediate",
|
||||||
"key": {
|
"key": {
|
||||||
"algo": "rsa",
|
"algo": "rsa",
|
||||||
"size": 2048
|
"size": 2048
|
||||||
},
|
},
|
||||||
"names": [
|
"names": [
|
||||||
{
|
{
|
||||||
"C": "FR",
|
"C": "FR",
|
||||||
"L": "Paris",
|
"L": "Paris",
|
||||||
|
@ -12,8 +12,8 @@
|
||||||
"OU": "Crowdsec Intermediate",
|
"OU": "Crowdsec Intermediate",
|
||||||
"ST": "France"
|
"ST": "France"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"ca": {
|
"ca": {
|
||||||
"expiry": "42720h"
|
"expiry": "42720h"
|
||||||
}
|
}
|
||||||
}
|
}
|
16
test/bats/testdata/cfssl/ca_root.json
vendored
Normal file
16
test/bats/testdata/cfssl/ca_root.json
vendored
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"CN": "CrowdSec Test CA",
|
||||||
|
"key": {
|
||||||
|
"algo": "rsa",
|
||||||
|
"size": 2048
|
||||||
|
},
|
||||||
|
"names": [
|
||||||
|
{
|
||||||
|
"C": "FR",
|
||||||
|
"L": "Paris",
|
||||||
|
"O": "Crowdsec",
|
||||||
|
"OU": "Crowdsec",
|
||||||
|
"ST": "France"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
71
test/bats/testdata/cfssl/profiles.json
vendored
71
test/bats/testdata/cfssl/profiles.json
vendored
|
@ -1,44 +1,37 @@
|
||||||
{
|
{
|
||||||
"signing": {
|
"signing": {
|
||||||
"default": {
|
"default": {
|
||||||
|
"expiry": "8760h"
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"intermediate_ca": {
|
||||||
|
"usages": [
|
||||||
|
"signing",
|
||||||
|
"key encipherment",
|
||||||
|
"cert sign",
|
||||||
|
"crl sign",
|
||||||
|
"server auth",
|
||||||
|
"client auth"
|
||||||
|
],
|
||||||
|
"expiry": "8760h",
|
||||||
|
"ca_constraint": {
|
||||||
|
"is_ca": true,
|
||||||
|
"max_path_len": 0,
|
||||||
|
"max_path_len_zero": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"usages": [
|
||||||
|
"server auth"
|
||||||
|
],
|
||||||
"expiry": "8760h"
|
"expiry": "8760h"
|
||||||
},
|
},
|
||||||
"profiles": {
|
"client": {
|
||||||
"intermediate_ca": {
|
"usages": [
|
||||||
"usages": [
|
"client auth"
|
||||||
"signing",
|
],
|
||||||
"digital signature",
|
"expiry": "8760h"
|
||||||
"key encipherment",
|
|
||||||
"cert sign",
|
|
||||||
"crl sign",
|
|
||||||
"server auth",
|
|
||||||
"client auth"
|
|
||||||
],
|
|
||||||
"expiry": "8760h",
|
|
||||||
"ca_constraint": {
|
|
||||||
"is_ca": true,
|
|
||||||
"max_path_len": 0,
|
|
||||||
"max_path_len_zero": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"server": {
|
|
||||||
"usages": [
|
|
||||||
"signing",
|
|
||||||
"digital signing",
|
|
||||||
"key encipherment",
|
|
||||||
"server auth"
|
|
||||||
],
|
|
||||||
"expiry": "8760h"
|
|
||||||
},
|
|
||||||
"client": {
|
|
||||||
"usages": [
|
|
||||||
"signing",
|
|
||||||
"digital signature",
|
|
||||||
"key encipherment",
|
|
||||||
"client auth"
|
|
||||||
],
|
|
||||||
"expiry": "8760h"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
24
test/bats/testdata/cfssl/server.json
vendored
24
test/bats/testdata/cfssl/server.json
vendored
|
@ -1,10 +1,10 @@
|
||||||
{
|
{
|
||||||
"CN": "localhost",
|
"CN": "localhost",
|
||||||
"key": {
|
"key": {
|
||||||
"algo": "rsa",
|
"algo": "rsa",
|
||||||
"size": 2048
|
"size": 2048
|
||||||
},
|
},
|
||||||
"names": [
|
"names": [
|
||||||
{
|
{
|
||||||
"C": "FR",
|
"C": "FR",
|
||||||
"L": "Paris",
|
"L": "Paris",
|
||||||
|
@ -12,9 +12,9 @@
|
||||||
"OU": "Crowdsec Server",
|
"OU": "Crowdsec Server",
|
||||||
"ST": "France"
|
"ST": "France"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"hosts": [
|
"hosts": [
|
||||||
"127.0.0.1",
|
"127.0.0.1",
|
||||||
"localhost"
|
"localhost"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -155,6 +155,11 @@ assert_log() {
|
||||||
}
|
}
|
||||||
export -f assert_log
|
export -f assert_log
|
||||||
|
|
||||||
|
cert_serial_number() {
|
||||||
|
cfssl certinfo -cert "$1" | jq -r '.serial_number'
|
||||||
|
}
|
||||||
|
export -f cert_serial_number
|
||||||
|
|
||||||
# Compare ignoring the key order, and allow "expected" without quoted identifiers.
|
# Compare ignoring the key order, and allow "expected" without quoted identifiers.
|
||||||
# Preserve the output variable in case the following commands require it.
|
# Preserve the output variable in case the following commands require it.
|
||||||
assert_json() {
|
assert_json() {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue