mirror of
https://github.com/openziti/ziti.git
synced 2026-09-10 16:55:41 +00:00
8297a817b7
* fixes #3734 enforce client certificate proof-of-possession for OIDC sessions - adds verifyCertProofOfPossession() in resolveOidcSession() to require TLS client cert matching a z_cfs fingerprint or SPIFFE ID when the OIDC token was issued with cert bindings - adds z_cae (CertAllowExpired) claim from auth policy, propagated through token issuance and refresh - adds TrustCache.VerifyClientCert() with tiered pool matching (first-party roots, trust anchors, third-party) and TTL cache - adds WrapIdentityWithCertValidation() on the router to verify client certs at the TLS level against RDM PublicKeys - adds IsFirstPartyCert() on the router to gate SPIFFE ID matching by checking whether the cert chains to the controller root CA - adds VerifySpiffeId() in common/spiffehlp with SpiffeMatchApiSession, SpiffeMatchIdentity, and SpiffeMatchNone return types - adds SPIFFE IDs to OTT and token enrollment certs (/identity/<id>) - enforces cert expiry by match type: API session certs must be valid, fingerprint-matched certs respect z_cae, legacy sessions skip checks - shallow-copies leaf certs before overriding time fields in all cert verification paths to avoid races on shared x509.Certificate pointers - fixes controllerRootCache setting inited=true before the ctrl channel is available, which permanently cached the failure
528 lines
16 KiB
Go
528 lines
16 KiB
Go
/*
|
|
Copyright NetFoundry Inc.
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
https://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package model
|
|
|
|
import (
|
|
"crypto/x509"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/jellydator/ttlcache/v3"
|
|
"github.com/michaelquigley/pfxlog"
|
|
nfpem "github.com/openziti/foundation/v2/pem"
|
|
"github.com/openziti/identity"
|
|
"github.com/openziti/ziti/v2/controller/storage/ast"
|
|
"github.com/openziti/ziti/v2/controller/storage/boltz"
|
|
"github.com/openziti/ziti/v2/common/pb/edge_cmd_pb"
|
|
"github.com/openziti/ziti/v2/controller/change"
|
|
"github.com/openziti/ziti/v2/controller/command"
|
|
"github.com/openziti/ziti/v2/controller/db"
|
|
"github.com/openziti/ziti/v2/controller/fields"
|
|
"github.com/openziti/ziti/v2/controller/models"
|
|
"go.etcd.io/bbolt"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
func NewCaManager(env Env) *CaManager {
|
|
manager := &CaManager{
|
|
baseEntityManager: newBaseEntityManager[*Ca, *db.Ca](env, env.GetStores().Ca),
|
|
}
|
|
manager.impl = manager
|
|
|
|
RegisterManagerDecoder[*Ca](env, manager)
|
|
|
|
env.GetStores().Ca.AddEntityEventListenerF(manager.onMutate, boltz.EntityDeletedAsync)
|
|
env.GetStores().Ca.AddEntityEventListenerF(manager.onMutate, boltz.EntityCreatedAsync)
|
|
env.GetStores().Ca.AddEntityEventListenerF(manager.onMutate, boltz.EntityUpdatedAsync)
|
|
|
|
return manager
|
|
}
|
|
|
|
const certVerifyCacheTTL = 10 * time.Minute
|
|
|
|
type TrustCache struct {
|
|
// static root certificates from the controller's root identity CA bundle (roots and intermediates)
|
|
staticFirstPartyTrustAnchors []*x509.Certificate
|
|
|
|
// a cert pool that mirrors `staticFirstPartyTrustAnchors`
|
|
staticFirstPartyTrustAnchorPool *x509.CertPool
|
|
|
|
// static root certificates from the controller's root identity CA bundle (roots only)
|
|
staticFirstPartyRoots []*x509.Certificate
|
|
|
|
// a cert pool that mirrors `staticFirstPartyRoots`
|
|
staticFirstPartyRootPool *x509.CertPool
|
|
|
|
// a set of trust anchors registered as 3rd party CAs that are active for authentication
|
|
thirdPartyTrustAnchors []*x509.Certificate
|
|
|
|
// a cert pool that mirrors `thirdPartyTrustAnchors`
|
|
thirdPartyTrustAnchorPool *x509.CertPool
|
|
|
|
// all 3rd party CA model items in fingerprint -> Ca form
|
|
activeThirdPartyCas map[string]*Ca
|
|
|
|
// a pool of all roots, intermediates and 3rd parties
|
|
allPool *x509.CertPool
|
|
|
|
// certOriginCache caches cert chain verification results by fingerprint, avoiding repeated
|
|
// x509.Verify calls for the same certificate. TLS handshake guarantees private key possession
|
|
// per-connection, so caching the chain verification result is safe.
|
|
certOriginCache *ttlcache.Cache[string, CertOrigin]
|
|
|
|
sync.RWMutex
|
|
initOnce sync.Once
|
|
}
|
|
|
|
// CertOrigin indicates whether a client certificate was issued by a first-party (internal) CA
|
|
// or a third-party CA. This distinction is used to gate SPIFFE ID matching, which is only
|
|
// valid for first-party certificates.
|
|
type CertOrigin int
|
|
|
|
const (
|
|
CertOriginUntrusted CertOrigin = iota
|
|
CertOriginFirstParty
|
|
CertOriginThirdParty
|
|
)
|
|
|
|
func (self *TrustCache) GetAllPool() *x509.CertPool {
|
|
self.RLock()
|
|
defer self.RUnlock()
|
|
|
|
if self.allPool == nil {
|
|
return x509.NewCertPool()
|
|
}
|
|
|
|
return self.allPool
|
|
}
|
|
|
|
// VerifyClientCert verifies a client certificate chain against the tiered trust pools and returns
|
|
// which pool matched. First-party pools (roots, then roots+intermediates) are tried first, then
|
|
// third-party. Returns CertOriginUntrusted if no pool validates the cert.
|
|
//
|
|
// When skipTimeCheck is true, certificate time validity (NotBefore/NotAfter) is bypassed during
|
|
// chain verification. This supports auth policies that allow expired certificates. The caller
|
|
// is responsible for enforcing expiry when appropriate.
|
|
func (self *TrustCache) VerifyClientCert(clientCerts []*x509.Certificate, skipTimeCheck bool) CertOrigin {
|
|
if len(clientCerts) == 0 {
|
|
return CertOriginUntrusted
|
|
}
|
|
|
|
self.RLock()
|
|
defer self.RUnlock()
|
|
|
|
intermediates := x509.NewCertPool()
|
|
for _, cert := range clientCerts[1:] {
|
|
intermediates.AddCert(cert)
|
|
}
|
|
|
|
// Shallow-copy the leaf cert so we can override time fields without mutating the original
|
|
// (which may be shared across concurrent HTTP/2 requests on the same TLS connection).
|
|
leafCopy := *clientCerts[0]
|
|
leaf := &leafCopy
|
|
if skipTimeCheck {
|
|
leaf.NotBefore = time.Now().Add(-1 * time.Hour)
|
|
leaf.NotAfter = time.Now().Add(1 * time.Hour)
|
|
}
|
|
|
|
if self.staticFirstPartyRootPool != nil {
|
|
opts := x509.VerifyOptions{
|
|
Roots: self.staticFirstPartyRootPool,
|
|
Intermediates: intermediates,
|
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
|
}
|
|
if _, err := leaf.Verify(opts); err == nil {
|
|
return CertOriginFirstParty
|
|
}
|
|
}
|
|
|
|
if self.staticFirstPartyTrustAnchorPool != nil {
|
|
opts := x509.VerifyOptions{
|
|
Roots: self.staticFirstPartyTrustAnchorPool,
|
|
Intermediates: intermediates,
|
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
|
}
|
|
if _, err := leaf.Verify(opts); err == nil {
|
|
return CertOriginFirstParty
|
|
}
|
|
}
|
|
|
|
if self.thirdPartyTrustAnchorPool != nil {
|
|
opts := x509.VerifyOptions{
|
|
Roots: self.thirdPartyTrustAnchorPool,
|
|
Intermediates: intermediates,
|
|
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
|
}
|
|
if _, err := leaf.Verify(opts); err == nil {
|
|
return CertOriginThirdParty
|
|
}
|
|
}
|
|
|
|
return CertOriginUntrusted
|
|
}
|
|
|
|
// VerifyClientCertCached is like VerifyClientCert but caches results by fingerprint for the
|
|
// configured TTL. Safe because TLS handshake proves private key possession per-connection.
|
|
func (self *TrustCache) VerifyClientCertCached(fingerprint string, clientCerts []*x509.Certificate, skipTimeCheck bool) CertOrigin {
|
|
if self.certOriginCache != nil {
|
|
if item := self.certOriginCache.Get(fingerprint); item != nil {
|
|
return item.Value()
|
|
}
|
|
}
|
|
|
|
origin := self.VerifyClientCert(clientCerts, skipTimeCheck)
|
|
|
|
if self.certOriginCache != nil && origin != CertOriginUntrusted {
|
|
self.certOriginCache.Set(fingerprint, origin, ttlcache.DefaultTTL)
|
|
}
|
|
|
|
return origin
|
|
}
|
|
|
|
type CaManager struct {
|
|
baseEntityManager[*Ca, *db.Ca]
|
|
cache TrustCache
|
|
}
|
|
|
|
func (self *CaManager) initCache() {
|
|
self.cache.certOriginCache = ttlcache.New[string, CertOrigin](
|
|
ttlcache.WithTTL[string, CertOrigin](certVerifyCacheTTL),
|
|
)
|
|
go self.cache.certOriginCache.Start()
|
|
|
|
err := self.RefreshActiveAuthCaCertCache()
|
|
if err != nil {
|
|
pfxlog.Logger().WithError(err).Info("failed to refresh active auth ca cert cache")
|
|
}
|
|
}
|
|
|
|
func (self *CaManager) GetTrustCache() *TrustCache {
|
|
self.cache.initOnce.Do(self.initCache)
|
|
return &self.cache
|
|
}
|
|
|
|
func (self *CaManager) RefreshActiveAuthCaCertCache() error {
|
|
self.cache.Lock()
|
|
defer self.cache.Unlock()
|
|
|
|
// invalidate cached cert verification results since trust pools are changing
|
|
if self.cache.certOriginCache != nil {
|
|
self.cache.certOriginCache.DeleteAll()
|
|
}
|
|
|
|
// for TLS config
|
|
allPool := x509.NewCertPool()
|
|
|
|
//legacy root + intermediates
|
|
var newStaticFirstPartyTrustAnchors []*x509.Certificate
|
|
newStaticFirstPartyTrustAnchorPool := x509.NewCertPool()
|
|
|
|
// ha and forward root only
|
|
var newStaticFirstPartyRoots []*x509.Certificate
|
|
newStaticFirstPartyRootPool := x509.NewCertPool()
|
|
|
|
var newThirdPartyTrustAnchors []*x509.Certificate
|
|
newThirdPartyTrustAnchorPool := x509.NewCertPool()
|
|
|
|
newActiveThirdPartyCas := map[string]*Ca{}
|
|
|
|
for _, cert := range self.env.GetConfig().Edge.CaCerts() {
|
|
//TODO: remove legacy trust anchors (root + intermediate) and use root only
|
|
newStaticFirstPartyTrustAnchors = append(newStaticFirstPartyTrustAnchors, cert)
|
|
newStaticFirstPartyTrustAnchorPool.AddCert(cert)
|
|
allPool.AddCert(cert)
|
|
|
|
if identity.IsRootCa(cert) {
|
|
newStaticFirstPartyRoots = append(newStaticFirstPartyRoots, cert)
|
|
newStaticFirstPartyRootPool.AddCert(cert)
|
|
}
|
|
}
|
|
|
|
err := self.Stream("isAuthEnabled = true and isVerified = true", func(ca *Ca, err error) error {
|
|
if ca == nil && err == nil {
|
|
return nil
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("error refreshing ca cert cache: %v", err)
|
|
}
|
|
|
|
caCerts := nfpem.PemStringToCertificates(ca.CertPem)
|
|
|
|
if len(caCerts) != 0 {
|
|
newActiveThirdPartyCas[ca.Fingerprint] = ca
|
|
newThirdPartyTrustAnchors = append(newThirdPartyTrustAnchors, caCerts[0])
|
|
newThirdPartyTrustAnchorPool.AddCert(caCerts[0])
|
|
allPool.AddCert(caCerts[0])
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
self.cache.staticFirstPartyTrustAnchors = newStaticFirstPartyTrustAnchors
|
|
self.cache.staticFirstPartyTrustAnchorPool = newStaticFirstPartyTrustAnchorPool
|
|
|
|
self.cache.staticFirstPartyRoots = newStaticFirstPartyRoots
|
|
self.cache.staticFirstPartyRootPool = newStaticFirstPartyRootPool
|
|
|
|
self.cache.thirdPartyTrustAnchors = newThirdPartyTrustAnchors
|
|
self.cache.thirdPartyTrustAnchorPool = newThirdPartyTrustAnchorPool
|
|
self.cache.activeThirdPartyCas = newActiveThirdPartyCas
|
|
|
|
self.cache.allPool = allPool
|
|
|
|
return err
|
|
}
|
|
|
|
func (self *CaManager) NewModelEntity() *Ca {
|
|
return &Ca{}
|
|
}
|
|
|
|
func (self *CaManager) Create(entity *Ca, ctx *change.Context) error {
|
|
return DispatchCreate[*Ca](self, entity, ctx)
|
|
}
|
|
|
|
func (self *CaManager) ApplyCreate(cmd *command.CreateEntityCommand[*Ca], ctx boltz.MutateContext) error {
|
|
_, err := self.createEntity(cmd.Entity, ctx)
|
|
|
|
return err
|
|
}
|
|
|
|
func (self *CaManager) Update(entity *Ca, checker fields.UpdatedFields, ctx *change.Context) error {
|
|
if checker != nil {
|
|
checker.RemoveFields(db.FieldCaIsVerified)
|
|
}
|
|
return DispatchUpdate[*Ca](self, entity, checker, ctx)
|
|
}
|
|
|
|
func (self *CaManager) ApplyUpdate(cmd *command.UpdateEntityCommand[*Ca], ctx boltz.MutateContext) error {
|
|
var checker boltz.FieldChecker = self
|
|
|
|
// isVerified should only be set by the Verified method. We remove isVerified
|
|
// from updated fields coming through Update method
|
|
if cmd.UpdatedFields != nil {
|
|
if cmd.UpdatedFields.IsUpdated(db.FieldCaIsVerified) {
|
|
checker = cmd.UpdatedFields
|
|
} else {
|
|
checker = &AndFieldChecker{first: self, second: cmd.UpdatedFields}
|
|
}
|
|
}
|
|
|
|
return self.updateEntity(cmd.Entity, checker, ctx)
|
|
}
|
|
|
|
func (self *CaManager) Read(id string) (*Ca, error) {
|
|
modelEntity := &Ca{}
|
|
if err := self.readEntity(id, modelEntity); err != nil {
|
|
return nil, err
|
|
}
|
|
return modelEntity, nil
|
|
}
|
|
|
|
func (self *CaManager) readInTx(tx *bbolt.Tx, id string) (*Ca, error) {
|
|
modelEntity := &Ca{}
|
|
if err := self.readEntityInTx(tx, id, modelEntity); err != nil {
|
|
return nil, err
|
|
}
|
|
return modelEntity, nil
|
|
}
|
|
|
|
func (self *CaManager) IsUpdated(field string) bool {
|
|
return strings.EqualFold(field, db.FieldName) ||
|
|
strings.EqualFold(field, boltz.FieldTags) ||
|
|
strings.EqualFold(field, db.FieldCaIsAutoCaEnrollmentEnabled) ||
|
|
strings.EqualFold(field, db.FieldCaIsOttCaEnrollmentEnabled) ||
|
|
strings.EqualFold(field, db.FieldCaIsAuthEnabled) ||
|
|
strings.EqualFold(field, db.FieldIdentityRoles) ||
|
|
strings.EqualFold(field, db.FieldCaIdentityNameFormat) ||
|
|
strings.HasPrefix(field, db.FieldCaExternalIdClaim+".")
|
|
}
|
|
|
|
func (self *CaManager) Verified(ca *Ca, ctx *change.Context) error {
|
|
ca.IsVerified = true
|
|
checker := &fields.UpdatedFieldsMap{
|
|
db.FieldCaIsVerified: struct{}{},
|
|
}
|
|
return DispatchUpdate[*Ca](self, ca, checker, ctx)
|
|
}
|
|
|
|
func (self *CaManager) Query(query string) (*CaListResult, error) {
|
|
result := &CaListResult{manager: self}
|
|
if err := self.ListWithHandler(query, result.collect); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (self *CaManager) Stream(query string, collect func(*Ca, error) error) error {
|
|
filter, err := ast.Parse(self.Store, query)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("could not parse query for streaming cas: %v", err)
|
|
}
|
|
|
|
return self.env.GetDb().View(func(tx *bbolt.Tx) error {
|
|
for cursor := self.Store.IterateIds(tx, filter); cursor.IsValid(); cursor.Next() {
|
|
current := cursor.Current()
|
|
|
|
ca, err := self.readInTx(tx, string(current))
|
|
if err := collect(ca, err); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return collect(nil, nil)
|
|
})
|
|
}
|
|
|
|
func (self *CaManager) Marshall(entity *Ca) ([]byte, error) {
|
|
tags, err := edge_cmd_pb.EncodeTags(entity.Tags)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var externalIdClaim *edge_cmd_pb.Ca_ExternalIdClaim
|
|
if entity.ExternalIdClaim != nil {
|
|
externalIdClaim = &edge_cmd_pb.Ca_ExternalIdClaim{
|
|
Location: entity.ExternalIdClaim.Location,
|
|
Matcher: entity.ExternalIdClaim.Matcher,
|
|
MatcherCriteria: entity.ExternalIdClaim.MatcherCriteria,
|
|
Parser: entity.ExternalIdClaim.Parser,
|
|
ParserCriteria: entity.ExternalIdClaim.ParserCriteria,
|
|
Index: entity.ExternalIdClaim.Index,
|
|
}
|
|
}
|
|
|
|
msg := &edge_cmd_pb.Ca{
|
|
Id: entity.Id,
|
|
Name: entity.Name,
|
|
Tags: tags,
|
|
Fingerprint: entity.Fingerprint,
|
|
CertPem: entity.CertPem,
|
|
IsVerified: entity.IsVerified,
|
|
VerificationToken: entity.VerificationToken,
|
|
IsAutoCaEnrollmentEnabled: entity.IsAutoCaEnrollmentEnabled,
|
|
IsOttCaEnrollmentEnabled: entity.IsOttCaEnrollmentEnabled,
|
|
IsAuthEnabled: entity.IsAuthEnabled,
|
|
IdentityRoles: entity.IdentityRoles,
|
|
IdentityNameFormat: entity.IdentityNameFormat,
|
|
ExternalIdClaim: externalIdClaim,
|
|
}
|
|
|
|
return proto.Marshal(msg)
|
|
}
|
|
|
|
func (self *CaManager) Unmarshall(bytes []byte) (*Ca, error) {
|
|
msg := &edge_cmd_pb.Ca{}
|
|
if err := proto.Unmarshal(bytes, msg); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var externalIdClaim *ExternalIdClaim
|
|
if msg.ExternalIdClaim != nil {
|
|
externalIdClaim = &ExternalIdClaim{
|
|
Location: msg.ExternalIdClaim.Location,
|
|
Matcher: msg.ExternalIdClaim.Matcher,
|
|
MatcherCriteria: msg.ExternalIdClaim.MatcherCriteria,
|
|
Parser: msg.ExternalIdClaim.Parser,
|
|
ParserCriteria: msg.ExternalIdClaim.ParserCriteria,
|
|
Index: msg.ExternalIdClaim.Index,
|
|
}
|
|
}
|
|
|
|
return &Ca{
|
|
BaseEntity: models.BaseEntity{
|
|
Id: msg.Id,
|
|
Tags: edge_cmd_pb.DecodeTags(msg.Tags),
|
|
},
|
|
Name: msg.Name,
|
|
Fingerprint: msg.Fingerprint,
|
|
CertPem: msg.CertPem,
|
|
IsVerified: msg.IsVerified,
|
|
VerificationToken: msg.VerificationToken,
|
|
IsAutoCaEnrollmentEnabled: msg.IsAutoCaEnrollmentEnabled,
|
|
IsOttCaEnrollmentEnabled: msg.IsOttCaEnrollmentEnabled,
|
|
IsAuthEnabled: msg.IsAuthEnabled,
|
|
IdentityRoles: msg.IdentityRoles,
|
|
IdentityNameFormat: msg.IdentityNameFormat,
|
|
ExternalIdClaim: externalIdClaim,
|
|
}, nil
|
|
}
|
|
|
|
func (self *CaManager) onMutate(_ *db.Ca) {
|
|
err := self.RefreshActiveAuthCaCertCache()
|
|
|
|
if err != nil {
|
|
pfxlog.Logger().WithError(err).Error("error refreshing active auth cas on mutate")
|
|
}
|
|
}
|
|
|
|
type CaListResult struct {
|
|
manager *CaManager
|
|
Cas []*Ca
|
|
models.QueryMetaData
|
|
}
|
|
|
|
func (result *CaListResult) collect(tx *bbolt.Tx, ids []string, queryMetaData *models.QueryMetaData) error {
|
|
result.QueryMetaData = *queryMetaData
|
|
for _, key := range ids {
|
|
entity, err := result.manager.readInTx(tx, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result.Cas = append(result.Cas, entity)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
FormatSentinelStart = "["
|
|
FormatSentinelEnd = "]"
|
|
FormatSymbolCaName = "caName"
|
|
FormatSymbolCaId = "caId"
|
|
FormatSymbolCommonName = "commonName"
|
|
FormatSymbolRequestedName = "requestedName"
|
|
FormatSymbolIdentityId = "identityId"
|
|
|
|
// DefaultCaIdentityNameFormat = "[caName] - [commonName]"
|
|
DefaultCaIdentityNameFormat = FormatSentinelStart + FormatSymbolCaName + FormatSentinelEnd + "-" + FormatSentinelStart + FormatSymbolCommonName + FormatSentinelEnd
|
|
)
|
|
|
|
type Formatter struct {
|
|
symbolValues map[string]string
|
|
sentinelStart string
|
|
sentinelEnd string
|
|
}
|
|
|
|
func NewFormatter(symbols map[string]string) *Formatter {
|
|
return &Formatter{
|
|
symbolValues: symbols,
|
|
sentinelStart: FormatSentinelStart,
|
|
sentinelEnd: FormatSentinelEnd,
|
|
}
|
|
}
|
|
|
|
func (formatter *Formatter) Format(name string) string {
|
|
for symbol, value := range formatter.symbolValues {
|
|
searchSymbol := formatter.sentinelStart + symbol + formatter.sentinelEnd
|
|
name = strings.ReplaceAll(name, searchSymbol, value)
|
|
}
|
|
|
|
return name
|
|
}
|