Merge branch 'master' into context-authority
commit
d461918eb0
@ -1,4 +1,20 @@
|
||||
### Description
|
||||
Please describe your pull request.
|
||||
<!---
|
||||
Please provide answers in the spaces below each prompt, where applicable.
|
||||
Not every PR requires responses for each prompt.
|
||||
Use your discretion.
|
||||
-->
|
||||
#### Name of feature:
|
||||
|
||||
#### Pain or issue this feature alleviates:
|
||||
|
||||
#### Why is this important to the project (if not answered above):
|
||||
|
||||
#### Is there documentation on how to use this feature? If so, where?
|
||||
|
||||
#### In what environments or workflows is this feature supported?
|
||||
|
||||
#### In what environments or workflows is this feature explicitly NOT supported (if any)?
|
||||
|
||||
#### Supporting links/other PRs/issues:
|
||||
|
||||
💔Thank you!
|
||||
|
@ -0,0 +1,517 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"go.step.sm/linkedca"
|
||||
|
||||
"github.com/smallstep/certificates/acme"
|
||||
"github.com/smallstep/certificates/api/read"
|
||||
"github.com/smallstep/certificates/api/render"
|
||||
"github.com/smallstep/certificates/authority"
|
||||
"github.com/smallstep/certificates/authority/admin"
|
||||
"github.com/smallstep/certificates/authority/policy"
|
||||
)
|
||||
|
||||
type policyAdminResponderInterface interface {
|
||||
GetAuthorityPolicy(w http.ResponseWriter, r *http.Request)
|
||||
CreateAuthorityPolicy(w http.ResponseWriter, r *http.Request)
|
||||
UpdateAuthorityPolicy(w http.ResponseWriter, r *http.Request)
|
||||
DeleteAuthorityPolicy(w http.ResponseWriter, r *http.Request)
|
||||
GetProvisionerPolicy(w http.ResponseWriter, r *http.Request)
|
||||
CreateProvisionerPolicy(w http.ResponseWriter, r *http.Request)
|
||||
UpdateProvisionerPolicy(w http.ResponseWriter, r *http.Request)
|
||||
DeleteProvisionerPolicy(w http.ResponseWriter, r *http.Request)
|
||||
GetACMEAccountPolicy(w http.ResponseWriter, r *http.Request)
|
||||
CreateACMEAccountPolicy(w http.ResponseWriter, r *http.Request)
|
||||
UpdateACMEAccountPolicy(w http.ResponseWriter, r *http.Request)
|
||||
DeleteACMEAccountPolicy(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
|
||||
// PolicyAdminResponder is responsible for writing ACME admin responses
|
||||
type PolicyAdminResponder struct {
|
||||
auth adminAuthority
|
||||
adminDB admin.DB
|
||||
acmeDB acme.DB
|
||||
isLinkedCA bool
|
||||
}
|
||||
|
||||
// NewACMEAdminResponder returns a new ACMEAdminResponder
|
||||
func NewPolicyAdminResponder(auth adminAuthority, adminDB admin.DB, acmeDB acme.DB) *PolicyAdminResponder {
|
||||
|
||||
var isLinkedCA bool
|
||||
if a, ok := adminDB.(interface{ IsLinkedCA() bool }); ok {
|
||||
isLinkedCA = a.IsLinkedCA()
|
||||
}
|
||||
|
||||
return &PolicyAdminResponder{
|
||||
auth: auth,
|
||||
adminDB: adminDB,
|
||||
acmeDB: acmeDB,
|
||||
isLinkedCA: isLinkedCA,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthorityPolicy handles the GET /admin/authority/policy request
|
||||
func (par *PolicyAdminResponder) GetAuthorityPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
authorityPolicy, err := par.auth.GetAuthorityPolicy(r.Context())
|
||||
if ae, ok := err.(*admin.Error); ok && !ae.IsType(admin.ErrorNotFoundType) {
|
||||
render.Error(w, admin.WrapErrorISE(ae, "error retrieving authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
if authorityPolicy == nil {
|
||||
render.Error(w, admin.NewError(admin.ErrorNotFoundType, "authority policy does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
render.ProtoJSONStatus(w, authorityPolicy, http.StatusOK)
|
||||
}
|
||||
|
||||
// CreateAuthorityPolicy handles the POST /admin/authority/policy request
|
||||
func (par *PolicyAdminResponder) CreateAuthorityPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
authorityPolicy, err := par.auth.GetAuthorityPolicy(ctx)
|
||||
|
||||
if ae, ok := err.(*admin.Error); ok && !ae.IsType(admin.ErrorNotFoundType) {
|
||||
render.Error(w, admin.WrapErrorISE(err, "error retrieving authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
if authorityPolicy != nil {
|
||||
adminErr := admin.NewError(admin.ErrorConflictType, "authority already has a policy")
|
||||
render.Error(w, adminErr)
|
||||
return
|
||||
}
|
||||
|
||||
var newPolicy = new(linkedca.Policy)
|
||||
if err := read.ProtoJSON(r.Body, newPolicy); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newPolicy.Deduplicate()
|
||||
|
||||
if err := validatePolicy(newPolicy); err != nil {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error validating authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
adm := linkedca.MustAdminFromContext(ctx)
|
||||
|
||||
var createdPolicy *linkedca.Policy
|
||||
if createdPolicy, err = par.auth.CreateAuthorityPolicy(ctx, adm, newPolicy); err != nil {
|
||||
if isBadRequest(err) {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error storing authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.Error(w, admin.WrapErrorISE(err, "error storing authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.ProtoJSONStatus(w, createdPolicy, http.StatusCreated)
|
||||
}
|
||||
|
||||
// UpdateAuthorityPolicy handles the PUT /admin/authority/policy request
|
||||
func (par *PolicyAdminResponder) UpdateAuthorityPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
authorityPolicy, err := par.auth.GetAuthorityPolicy(ctx)
|
||||
|
||||
if ae, ok := err.(*admin.Error); ok && !ae.IsType(admin.ErrorNotFoundType) {
|
||||
render.Error(w, admin.WrapErrorISE(err, "error retrieving authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
if authorityPolicy == nil {
|
||||
render.Error(w, admin.NewError(admin.ErrorNotFoundType, "authority policy does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
var newPolicy = new(linkedca.Policy)
|
||||
if err := read.ProtoJSON(r.Body, newPolicy); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newPolicy.Deduplicate()
|
||||
|
||||
if err := validatePolicy(newPolicy); err != nil {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error validating authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
adm := linkedca.MustAdminFromContext(ctx)
|
||||
|
||||
var updatedPolicy *linkedca.Policy
|
||||
if updatedPolicy, err = par.auth.UpdateAuthorityPolicy(ctx, adm, newPolicy); err != nil {
|
||||
if isBadRequest(err) {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error updating authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.Error(w, admin.WrapErrorISE(err, "error updating authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.ProtoJSONStatus(w, updatedPolicy, http.StatusOK)
|
||||
}
|
||||
|
||||
// DeleteAuthorityPolicy handles the DELETE /admin/authority/policy request
|
||||
func (par *PolicyAdminResponder) DeleteAuthorityPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
authorityPolicy, err := par.auth.GetAuthorityPolicy(ctx)
|
||||
|
||||
if ae, ok := err.(*admin.Error); ok && !ae.IsType(admin.ErrorNotFoundType) {
|
||||
render.Error(w, admin.WrapErrorISE(ae, "error retrieving authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
if authorityPolicy == nil {
|
||||
render.Error(w, admin.NewError(admin.ErrorNotFoundType, "authority policy does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := par.auth.RemoveAuthorityPolicy(ctx); err != nil {
|
||||
render.Error(w, admin.WrapErrorISE(err, "error deleting authority policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.JSONStatus(w, DeleteResponse{Status: "ok"}, http.StatusOK)
|
||||
}
|
||||
|
||||
// GetProvisionerPolicy handles the GET /admin/provisioners/{name}/policy request
|
||||
func (par *PolicyAdminResponder) GetProvisionerPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
prov := linkedca.MustProvisionerFromContext(r.Context())
|
||||
|
||||
provisionerPolicy := prov.GetPolicy()
|
||||
if provisionerPolicy == nil {
|
||||
render.Error(w, admin.NewError(admin.ErrorNotFoundType, "provisioner policy does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
render.ProtoJSONStatus(w, provisionerPolicy, http.StatusOK)
|
||||
}
|
||||
|
||||
// CreateProvisionerPolicy handles the POST /admin/provisioners/{name}/policy request
|
||||
func (par *PolicyAdminResponder) CreateProvisionerPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
prov := linkedca.MustProvisionerFromContext(ctx)
|
||||
|
||||
provisionerPolicy := prov.GetPolicy()
|
||||
if provisionerPolicy != nil {
|
||||
adminErr := admin.NewError(admin.ErrorConflictType, "provisioner %s already has a policy", prov.Name)
|
||||
render.Error(w, adminErr)
|
||||
return
|
||||
}
|
||||
|
||||
var newPolicy = new(linkedca.Policy)
|
||||
if err := read.ProtoJSON(r.Body, newPolicy); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newPolicy.Deduplicate()
|
||||
|
||||
if err := validatePolicy(newPolicy); err != nil {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error validating provisioner policy"))
|
||||
return
|
||||
}
|
||||
|
||||
prov.Policy = newPolicy
|
||||
|
||||
if err := par.auth.UpdateProvisioner(ctx, prov); err != nil {
|
||||
if isBadRequest(err) {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error creating provisioner policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.Error(w, admin.WrapErrorISE(err, "error creating provisioner policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.ProtoJSONStatus(w, newPolicy, http.StatusCreated)
|
||||
}
|
||||
|
||||
// UpdateProvisionerPolicy handles the PUT /admin/provisioners/{name}/policy request
|
||||
func (par *PolicyAdminResponder) UpdateProvisionerPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
prov := linkedca.MustProvisionerFromContext(ctx)
|
||||
|
||||
provisionerPolicy := prov.GetPolicy()
|
||||
if provisionerPolicy == nil {
|
||||
render.Error(w, admin.NewError(admin.ErrorNotFoundType, "provisioner policy does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
var newPolicy = new(linkedca.Policy)
|
||||
if err := read.ProtoJSON(r.Body, newPolicy); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newPolicy.Deduplicate()
|
||||
|
||||
if err := validatePolicy(newPolicy); err != nil {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error validating provisioner policy"))
|
||||
return
|
||||
}
|
||||
|
||||
prov.Policy = newPolicy
|
||||
if err := par.auth.UpdateProvisioner(ctx, prov); err != nil {
|
||||
if isBadRequest(err) {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error updating provisioner policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.Error(w, admin.WrapErrorISE(err, "error updating provisioner policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.ProtoJSONStatus(w, newPolicy, http.StatusOK)
|
||||
}
|
||||
|
||||
// DeleteProvisionerPolicy handles the DELETE /admin/provisioners/{name}/policy request
|
||||
func (par *PolicyAdminResponder) DeleteProvisionerPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
prov := linkedca.MustProvisionerFromContext(ctx)
|
||||
|
||||
if prov.Policy == nil {
|
||||
render.Error(w, admin.NewError(admin.ErrorNotFoundType, "provisioner policy does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
// remove the policy
|
||||
prov.Policy = nil
|
||||
|
||||
if err := par.auth.UpdateProvisioner(ctx, prov); err != nil {
|
||||
render.Error(w, admin.WrapErrorISE(err, "error deleting provisioner policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.JSONStatus(w, DeleteResponse{Status: "ok"}, http.StatusOK)
|
||||
}
|
||||
|
||||
func (par *PolicyAdminResponder) GetACMEAccountPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
eak := linkedca.MustExternalAccountKeyFromContext(ctx)
|
||||
|
||||
eakPolicy := eak.GetPolicy()
|
||||
if eakPolicy == nil {
|
||||
render.Error(w, admin.NewError(admin.ErrorNotFoundType, "ACME EAK policy does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
render.ProtoJSONStatus(w, eakPolicy, http.StatusOK)
|
||||
}
|
||||
|
||||
func (par *PolicyAdminResponder) CreateACMEAccountPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
prov := linkedca.MustProvisionerFromContext(ctx)
|
||||
eak := linkedca.MustExternalAccountKeyFromContext(ctx)
|
||||
|
||||
eakPolicy := eak.GetPolicy()
|
||||
if eakPolicy != nil {
|
||||
adminErr := admin.NewError(admin.ErrorConflictType, "ACME EAK %s already has a policy", eak.Id)
|
||||
render.Error(w, adminErr)
|
||||
return
|
||||
}
|
||||
|
||||
var newPolicy = new(linkedca.Policy)
|
||||
if err := read.ProtoJSON(r.Body, newPolicy); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newPolicy.Deduplicate()
|
||||
|
||||
if err := validatePolicy(newPolicy); err != nil {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error validating ACME EAK policy"))
|
||||
return
|
||||
}
|
||||
|
||||
eak.Policy = newPolicy
|
||||
|
||||
acmeEAK := linkedEAKToCertificates(eak)
|
||||
if err := par.acmeDB.UpdateExternalAccountKey(ctx, prov.GetId(), acmeEAK); err != nil {
|
||||
render.Error(w, admin.WrapErrorISE(err, "error creating ACME EAK policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.ProtoJSONStatus(w, newPolicy, http.StatusCreated)
|
||||
}
|
||||
|
||||
func (par *PolicyAdminResponder) UpdateACMEAccountPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
prov := linkedca.MustProvisionerFromContext(ctx)
|
||||
eak := linkedca.MustExternalAccountKeyFromContext(ctx)
|
||||
|
||||
eakPolicy := eak.GetPolicy()
|
||||
if eakPolicy == nil {
|
||||
render.Error(w, admin.NewError(admin.ErrorNotFoundType, "ACME EAK policy does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
var newPolicy = new(linkedca.Policy)
|
||||
if err := read.ProtoJSON(r.Body, newPolicy); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newPolicy.Deduplicate()
|
||||
|
||||
if err := validatePolicy(newPolicy); err != nil {
|
||||
render.Error(w, admin.WrapError(admin.ErrorBadRequestType, err, "error validating ACME EAK policy"))
|
||||
return
|
||||
}
|
||||
|
||||
eak.Policy = newPolicy
|
||||
acmeEAK := linkedEAKToCertificates(eak)
|
||||
if err := par.acmeDB.UpdateExternalAccountKey(ctx, prov.GetId(), acmeEAK); err != nil {
|
||||
render.Error(w, admin.WrapErrorISE(err, "error updating ACME EAK policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.ProtoJSONStatus(w, newPolicy, http.StatusOK)
|
||||
}
|
||||
|
||||
func (par *PolicyAdminResponder) DeleteACMEAccountPolicy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := par.blockLinkedCA(); err != nil {
|
||||
render.Error(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
prov := linkedca.MustProvisionerFromContext(ctx)
|
||||
eak := linkedca.MustExternalAccountKeyFromContext(ctx)
|
||||
|
||||
eakPolicy := eak.GetPolicy()
|
||||
if eakPolicy == nil {
|
||||
render.Error(w, admin.NewError(admin.ErrorNotFoundType, "ACME EAK policy does not exist"))
|
||||
return
|
||||
}
|
||||
|
||||
// remove the policy
|
||||
eak.Policy = nil
|
||||
|
||||
acmeEAK := linkedEAKToCertificates(eak)
|
||||
if err := par.acmeDB.UpdateExternalAccountKey(ctx, prov.GetId(), acmeEAK); err != nil {
|
||||
render.Error(w, admin.WrapErrorISE(err, "error deleting ACME EAK policy"))
|
||||
return
|
||||
}
|
||||
|
||||
render.JSONStatus(w, DeleteResponse{Status: "ok"}, http.StatusOK)
|
||||
}
|
||||
|
||||
// blockLinkedCA blocks all API operations on linked deployments
|
||||
func (par *PolicyAdminResponder) blockLinkedCA() error {
|
||||
// temporary blocking linked deployments
|
||||
if par.isLinkedCA {
|
||||
return admin.NewError(admin.ErrorNotImplementedType, "policy operations not yet supported in linked deployments")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isBadRequest checks if an error should result in a bad request error
|
||||
// returned to the client.
|
||||
func isBadRequest(err error) bool {
|
||||
var pe *authority.PolicyError
|
||||
isPolicyError := errors.As(err, &pe)
|
||||
return isPolicyError && (pe.Typ == authority.AdminLockOut || pe.Typ == authority.EvaluationFailure || pe.Typ == authority.ConfigurationFailure)
|
||||
}
|
||||
|
||||
func validatePolicy(p *linkedca.Policy) error {
|
||||
|
||||
// convert the policy; return early if nil
|
||||
options := policy.LinkedToCertificates(p)
|
||||
if options == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
// Initialize a temporary x509 allow/deny policy engine
|
||||
if _, err = policy.NewX509PolicyEngine(options.GetX509Options()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize a temporary SSH allow/deny policy engine for host certificates
|
||||
if _, err = policy.NewSSHHostPolicyEngine(options.GetSSHOptions()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize a temporary SSH allow/deny policy engine for user certificates
|
||||
if _, err = policy.NewSSHUserPolicyEngine(options.GetSSHOptions()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,339 @@
|
||||
package nosql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"go.step.sm/linkedca"
|
||||
|
||||
"github.com/smallstep/certificates/authority/admin"
|
||||
"github.com/smallstep/nosql"
|
||||
)
|
||||
|
||||
type dbX509Policy struct {
|
||||
Allow *dbX509Names `json:"allow,omitempty"`
|
||||
Deny *dbX509Names `json:"deny,omitempty"`
|
||||
AllowWildcardNames bool `json:"allow_wildcard_names,omitempty"`
|
||||
}
|
||||
|
||||
type dbX509Names struct {
|
||||
CommonNames []string `json:"cn,omitempty"`
|
||||
DNSDomains []string `json:"dns,omitempty"`
|
||||
IPRanges []string `json:"ip,omitempty"`
|
||||
EmailAddresses []string `json:"email,omitempty"`
|
||||
URIDomains []string `json:"uri,omitempty"`
|
||||
}
|
||||
|
||||
type dbSSHPolicy struct {
|
||||
// User contains SSH user certificate options.
|
||||
User *dbSSHUserPolicy `json:"user,omitempty"`
|
||||
// Host contains SSH host certificate options.
|
||||
Host *dbSSHHostPolicy `json:"host,omitempty"`
|
||||
}
|
||||
|
||||
type dbSSHHostPolicy struct {
|
||||
Allow *dbSSHHostNames `json:"allow,omitempty"`
|
||||
Deny *dbSSHHostNames `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
type dbSSHHostNames struct {
|
||||
DNSDomains []string `json:"dns,omitempty"`
|
||||
IPRanges []string `json:"ip,omitempty"`
|
||||
Principals []string `json:"principal,omitempty"`
|
||||
}
|
||||
|
||||
type dbSSHUserPolicy struct {
|
||||
Allow *dbSSHUserNames `json:"allow,omitempty"`
|
||||
Deny *dbSSHUserNames `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
type dbSSHUserNames struct {
|
||||
EmailAddresses []string `json:"email,omitempty"`
|
||||
Principals []string `json:"principal,omitempty"`
|
||||
}
|
||||
|
||||
type dbPolicy struct {
|
||||
X509 *dbX509Policy `json:"x509,omitempty"`
|
||||
SSH *dbSSHPolicy `json:"ssh,omitempty"`
|
||||
}
|
||||
|
||||
type dbAuthorityPolicy struct {
|
||||
ID string `json:"id"`
|
||||
AuthorityID string `json:"authorityID"`
|
||||
Policy *dbPolicy `json:"policy,omitempty"`
|
||||
}
|
||||
|
||||
func (dbap *dbAuthorityPolicy) convert() *linkedca.Policy {
|
||||
if dbap == nil {
|
||||
return nil
|
||||
}
|
||||
return dbToLinked(dbap.Policy)
|
||||
}
|
||||
|
||||
func (db *DB) getDBAuthorityPolicyBytes(ctx context.Context, authorityID string) ([]byte, error) {
|
||||
data, err := db.db.Get(authorityPoliciesTable, []byte(authorityID))
|
||||
if nosql.IsErrNotFound(err) {
|
||||
return nil, admin.NewError(admin.ErrorNotFoundType, "authority policy not found")
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("error loading authority policy: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (db *DB) unmarshalDBAuthorityPolicy(data []byte) (*dbAuthorityPolicy, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var dba = new(dbAuthorityPolicy)
|
||||
if err := json.Unmarshal(data, dba); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling policy bytes into dbAuthorityPolicy: %w", err)
|
||||
}
|
||||
return dba, nil
|
||||
}
|
||||
|
||||
func (db *DB) getDBAuthorityPolicy(ctx context.Context, authorityID string) (*dbAuthorityPolicy, error) {
|
||||
data, err := db.getDBAuthorityPolicyBytes(ctx, authorityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbap, err := db.unmarshalDBAuthorityPolicy(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dbap == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if dbap.AuthorityID != authorityID {
|
||||
return nil, admin.NewError(admin.ErrorAuthorityMismatchType,
|
||||
"authority policy is not owned by authority %s", authorityID)
|
||||
}
|
||||
return dbap, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateAuthorityPolicy(ctx context.Context, policy *linkedca.Policy) error {
|
||||
|
||||
dbap := &dbAuthorityPolicy{
|
||||
ID: db.authorityID,
|
||||
AuthorityID: db.authorityID,
|
||||
Policy: linkedToDB(policy),
|
||||
}
|
||||
|
||||
if err := db.save(ctx, dbap.ID, dbap, nil, "authority_policy", authorityPoliciesTable); err != nil {
|
||||
return admin.WrapErrorISE(err, "error creating authority policy")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) GetAuthorityPolicy(ctx context.Context) (*linkedca.Policy, error) {
|
||||
dbap, err := db.getDBAuthorityPolicy(ctx, db.authorityID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dbap.convert(), nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateAuthorityPolicy(ctx context.Context, policy *linkedca.Policy) error {
|
||||
old, err := db.getDBAuthorityPolicy(ctx, db.authorityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbap := &dbAuthorityPolicy{
|
||||
ID: db.authorityID,
|
||||
AuthorityID: db.authorityID,
|
||||
Policy: linkedToDB(policy),
|
||||
}
|
||||
|
||||
if err := db.save(ctx, dbap.ID, dbap, old, "authority_policy", authorityPoliciesTable); err != nil {
|
||||
return admin.WrapErrorISE(err, "error updating authority policy")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteAuthorityPolicy(ctx context.Context) error {
|
||||
old, err := db.getDBAuthorityPolicy(ctx, db.authorityID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.save(ctx, old.ID, nil, old, "authority_policy", authorityPoliciesTable); err != nil {
|
||||
return admin.WrapErrorISE(err, "error deleting authority policy")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func dbToLinked(p *dbPolicy) *linkedca.Policy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
r := &linkedca.Policy{}
|
||||
if x509 := p.X509; x509 != nil {
|
||||
r.X509 = &linkedca.X509Policy{}
|
||||
if allow := x509.Allow; allow != nil {
|
||||
r.X509.Allow = &linkedca.X509Names{}
|
||||
r.X509.Allow.Dns = allow.DNSDomains
|
||||
r.X509.Allow.Emails = allow.EmailAddresses
|
||||
r.X509.Allow.Ips = allow.IPRanges
|
||||
r.X509.Allow.Uris = allow.URIDomains
|
||||
r.X509.Allow.CommonNames = allow.CommonNames
|
||||
}
|
||||
if deny := x509.Deny; deny != nil {
|
||||
r.X509.Deny = &linkedca.X509Names{}
|
||||
r.X509.Deny.Dns = deny.DNSDomains
|
||||
r.X509.Deny.Emails = deny.EmailAddresses
|
||||
r.X509.Deny.Ips = deny.IPRanges
|
||||
r.X509.Deny.Uris = deny.URIDomains
|
||||
r.X509.Deny.CommonNames = deny.CommonNames
|
||||
}
|
||||
r.X509.AllowWildcardNames = x509.AllowWildcardNames
|
||||
}
|
||||
if ssh := p.SSH; ssh != nil {
|
||||
r.Ssh = &linkedca.SSHPolicy{}
|
||||
if host := ssh.Host; host != nil {
|
||||
r.Ssh.Host = &linkedca.SSHHostPolicy{}
|
||||
if allow := host.Allow; allow != nil {
|
||||
r.Ssh.Host.Allow = &linkedca.SSHHostNames{}
|
||||
r.Ssh.Host.Allow.Dns = allow.DNSDomains
|
||||
r.Ssh.Host.Allow.Ips = allow.IPRanges
|
||||
r.Ssh.Host.Allow.Principals = allow.Principals
|
||||
}
|
||||
if deny := host.Deny; deny != nil {
|
||||
r.Ssh.Host.Deny = &linkedca.SSHHostNames{}
|
||||
r.Ssh.Host.Deny.Dns = deny.DNSDomains
|
||||
r.Ssh.Host.Deny.Ips = deny.IPRanges
|
||||
r.Ssh.Host.Deny.Principals = deny.Principals
|
||||
}
|
||||
}
|
||||
if user := ssh.User; user != nil {
|
||||
r.Ssh.User = &linkedca.SSHUserPolicy{}
|
||||
if allow := user.Allow; allow != nil {
|
||||
r.Ssh.User.Allow = &linkedca.SSHUserNames{}
|
||||
r.Ssh.User.Allow.Emails = allow.EmailAddresses
|
||||
r.Ssh.User.Allow.Principals = allow.Principals
|
||||
}
|
||||
if deny := user.Deny; deny != nil {
|
||||
r.Ssh.User.Deny = &linkedca.SSHUserNames{}
|
||||
r.Ssh.User.Deny.Emails = deny.EmailAddresses
|
||||
r.Ssh.User.Deny.Principals = deny.Principals
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func linkedToDB(p *linkedca.Policy) *dbPolicy {
|
||||
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// return early if x509 nor SSH is set
|
||||
if p.GetX509() == nil && p.GetSsh() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := &dbPolicy{}
|
||||
// fill x509 policy configuration
|
||||
if x509 := p.GetX509(); x509 != nil {
|
||||
r.X509 = &dbX509Policy{}
|
||||
if allow := x509.GetAllow(); allow != nil {
|
||||
r.X509.Allow = &dbX509Names{}
|
||||
if allow.Dns != nil {
|
||||
r.X509.Allow.DNSDomains = allow.Dns
|
||||
}
|
||||
if allow.Ips != nil {
|
||||
r.X509.Allow.IPRanges = allow.Ips
|
||||
}
|
||||
if allow.Emails != nil {
|
||||
r.X509.Allow.EmailAddresses = allow.Emails
|
||||
}
|
||||
if allow.Uris != nil {
|
||||
r.X509.Allow.URIDomains = allow.Uris
|
||||
}
|
||||
if allow.CommonNames != nil {
|
||||
r.X509.Allow.CommonNames = allow.CommonNames
|
||||
}
|
||||
}
|
||||
if deny := x509.GetDeny(); deny != nil {
|
||||
r.X509.Deny = &dbX509Names{}
|
||||
if deny.Dns != nil {
|
||||
r.X509.Deny.DNSDomains = deny.Dns
|
||||
}
|
||||
if deny.Ips != nil {
|
||||
r.X509.Deny.IPRanges = deny.Ips
|
||||
}
|
||||
if deny.Emails != nil {
|
||||
r.X509.Deny.EmailAddresses = deny.Emails
|
||||
}
|
||||
if deny.Uris != nil {
|
||||
r.X509.Deny.URIDomains = deny.Uris
|
||||
}
|
||||
if deny.CommonNames != nil {
|
||||
r.X509.Deny.CommonNames = deny.CommonNames
|
||||
}
|
||||
}
|
||||
|
||||
r.X509.AllowWildcardNames = x509.GetAllowWildcardNames()
|
||||
}
|
||||
|
||||
// fill ssh policy configuration
|
||||
if ssh := p.GetSsh(); ssh != nil {
|
||||
r.SSH = &dbSSHPolicy{}
|
||||
if host := ssh.GetHost(); host != nil {
|
||||
r.SSH.Host = &dbSSHHostPolicy{}
|
||||
if allow := host.GetAllow(); allow != nil {
|
||||
r.SSH.Host.Allow = &dbSSHHostNames{}
|
||||
if allow.Dns != nil {
|
||||
r.SSH.Host.Allow.DNSDomains = allow.Dns
|
||||
}
|
||||
if allow.Ips != nil {
|
||||
r.SSH.Host.Allow.IPRanges = allow.Ips
|
||||
}
|
||||
if allow.Principals != nil {
|
||||
r.SSH.Host.Allow.Principals = allow.Principals
|
||||
}
|
||||
}
|
||||
if deny := host.GetDeny(); deny != nil {
|
||||
r.SSH.Host.Deny = &dbSSHHostNames{}
|
||||
if deny.Dns != nil {
|
||||
r.SSH.Host.Deny.DNSDomains = deny.Dns
|
||||
}
|
||||
if deny.Ips != nil {
|
||||
r.SSH.Host.Deny.IPRanges = deny.Ips
|
||||
}
|
||||
if deny.Principals != nil {
|
||||
r.SSH.Host.Deny.Principals = deny.Principals
|
||||
}
|
||||
}
|
||||
}
|
||||
if user := ssh.GetUser(); user != nil {
|
||||
r.SSH.User = &dbSSHUserPolicy{}
|
||||
if allow := user.GetAllow(); allow != nil {
|
||||
r.SSH.User.Allow = &dbSSHUserNames{}
|
||||
if allow.Emails != nil {
|
||||
r.SSH.User.Allow.EmailAddresses = allow.Emails
|
||||
}
|
||||
if allow.Principals != nil {
|
||||
r.SSH.User.Allow.Principals = allow.Principals
|
||||
}
|
||||
}
|
||||
if deny := user.GetDeny(); deny != nil {
|
||||
r.SSH.User.Deny = &dbSSHUserNames{}
|
||||
if deny.Emails != nil {
|
||||
r.SSH.User.Deny.EmailAddresses = deny.Emails
|
||||
}
|
||||
if deny.Principals != nil {
|
||||
r.SSH.User.Deny.Principals = deny.Principals
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,258 @@
|
||||
package authority
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.step.sm/linkedca"
|
||||
|
||||
"github.com/smallstep/certificates/authority/admin"
|
||||
authPolicy "github.com/smallstep/certificates/authority/policy"
|
||||
policy "github.com/smallstep/certificates/policy"
|
||||
)
|
||||
|
||||
type policyErrorType int
|
||||
|
||||
const (
|
||||
AdminLockOut policyErrorType = iota + 1
|
||||
StoreFailure
|
||||
ReloadFailure
|
||||
ConfigurationFailure
|
||||
EvaluationFailure
|
||||
InternalFailure
|
||||
)
|
||||
|
||||
type PolicyError struct {
|
||||
Typ policyErrorType
|
||||
Err error
|
||||
}
|
||||
|
||||
func (p *PolicyError) Error() string {
|
||||
return p.Err.Error()
|
||||
}
|
||||
|
||||
func (a *Authority) GetAuthorityPolicy(ctx context.Context) (*linkedca.Policy, error) {
|
||||
a.adminMutex.Lock()
|
||||
defer a.adminMutex.Unlock()
|
||||
|
||||
p, err := a.adminDB.GetAuthorityPolicy(ctx)
|
||||
if err != nil {
|
||||
return nil, &PolicyError{
|
||||
Typ: InternalFailure,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *Authority) CreateAuthorityPolicy(ctx context.Context, adm *linkedca.Admin, p *linkedca.Policy) (*linkedca.Policy, error) {
|
||||
a.adminMutex.Lock()
|
||||
defer a.adminMutex.Unlock()
|
||||
|
||||
if err := a.checkAuthorityPolicy(ctx, adm, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := a.adminDB.CreateAuthorityPolicy(ctx, p); err != nil {
|
||||
return nil, &PolicyError{
|
||||
Typ: StoreFailure,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.reloadPolicyEngines(ctx); err != nil {
|
||||
return nil, &PolicyError{
|
||||
Typ: ReloadFailure,
|
||||
Err: fmt.Errorf("error reloading policy engines when creating authority policy: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *Authority) UpdateAuthorityPolicy(ctx context.Context, adm *linkedca.Admin, p *linkedca.Policy) (*linkedca.Policy, error) {
|
||||
a.adminMutex.Lock()
|
||||
defer a.adminMutex.Unlock()
|
||||
|
||||
if err := a.checkAuthorityPolicy(ctx, adm, p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := a.adminDB.UpdateAuthorityPolicy(ctx, p); err != nil {
|
||||
return nil, &PolicyError{
|
||||
Typ: StoreFailure,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.reloadPolicyEngines(ctx); err != nil {
|
||||
return nil, &PolicyError{
|
||||
Typ: ReloadFailure,
|
||||
Err: fmt.Errorf("error reloading policy engines when updating authority policy: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (a *Authority) RemoveAuthorityPolicy(ctx context.Context) error {
|
||||
a.adminMutex.Lock()
|
||||
defer a.adminMutex.Unlock()
|
||||
|
||||
if err := a.adminDB.DeleteAuthorityPolicy(ctx); err != nil {
|
||||
return &PolicyError{
|
||||
Typ: StoreFailure,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
if err := a.reloadPolicyEngines(ctx); err != nil {
|
||||
return &PolicyError{
|
||||
Typ: ReloadFailure,
|
||||
Err: fmt.Errorf("error reloading policy engines when deleting authority policy: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Authority) checkAuthorityPolicy(ctx context.Context, currentAdmin *linkedca.Admin, p *linkedca.Policy) error {
|
||||
|
||||
// no policy and thus nothing to evaluate; return early
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// get all current admins from the database
|
||||
allAdmins, err := a.adminDB.GetAdmins(ctx)
|
||||
if err != nil {
|
||||
return &PolicyError{
|
||||
Typ: InternalFailure,
|
||||
Err: fmt.Errorf("error retrieving admins: %w", err),
|
||||
}
|
||||
}
|
||||
|
||||
return a.checkPolicy(ctx, currentAdmin, allAdmins, p)
|
||||
}
|
||||
|
||||
func (a *Authority) checkProvisionerPolicy(ctx context.Context, currentAdmin *linkedca.Admin, provName string, p *linkedca.Policy) error {
|
||||
|
||||
// no policy and thus nothing to evaluate; return early
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// get all admins for the provisioner; ignoring case in which they're not found
|
||||
allProvisionerAdmins, _ := a.admins.LoadByProvisioner(provName)
|
||||
|
||||
return a.checkPolicy(ctx, currentAdmin, allProvisionerAdmins, p)
|
||||
}
|
||||
|
||||
// checkPolicy checks if a new or updated policy configuration results in the user
|
||||
// locking themselves or other admins out of the CA.
|
||||
func (a *Authority) checkPolicy(ctx context.Context, currentAdmin *linkedca.Admin, otherAdmins []*linkedca.Admin, p *linkedca.Policy) error {
|
||||
|
||||
// convert the policy; return early if nil
|
||||
policyOptions := authPolicy.LinkedToCertificates(p)
|
||||
if policyOptions == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
engine, err := authPolicy.NewX509PolicyEngine(policyOptions.GetX509Options())
|
||||
if err != nil {
|
||||
return &PolicyError{
|
||||
Typ: ConfigurationFailure,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
// when an empty X.509 policy is provided, the resulting engine is nil
|
||||
// and there's no policy to evaluate.
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO(hs): Provide option to force the policy, even when the admin subject would be locked out?
|
||||
|
||||
// check if the admin user that instructed the authority policy to be
|
||||
// created or updated, would still be allowed when the provided policy
|
||||
// would be applied.
|
||||
sans := []string{currentAdmin.GetSubject()}
|
||||
if err := isAllowed(engine, sans); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// loop through admins to verify that none of them would be
|
||||
// locked out when the new policy were to be applied. Returns
|
||||
// an error with a message that includes the admin subject that
|
||||
// would be locked out.
|
||||
for _, adm := range otherAdmins {
|
||||
sans = []string{adm.GetSubject()}
|
||||
if err := isAllowed(engine, sans); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(hs): mask the error message for non-super admins?
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadPolicyEngines reloads x509 and SSH policy engines using
|
||||
// configuration stored in the DB or from the configuration file.
|
||||
func (a *Authority) reloadPolicyEngines(ctx context.Context) error {
|
||||
var (
|
||||
err error
|
||||
policyOptions *authPolicy.Options
|
||||
)
|
||||
|
||||
if a.config.AuthorityConfig.EnableAdmin {
|
||||
|
||||
// temporarily disable policy loading when LinkedCA is in use
|
||||
if _, ok := a.adminDB.(*linkedCaClient); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
linkedPolicy, err := a.adminDB.GetAuthorityPolicy(ctx)
|
||||
if err != nil {
|
||||
var ae *admin.Error
|
||||
if isAdminError := errors.As(err, &ae); (isAdminError && ae.Type != admin.ErrorNotFoundType.String()) || !isAdminError {
|
||||
return fmt.Errorf("error getting policy to (re)load policy engines: %w", err)
|
||||
}
|
||||
}
|
||||
policyOptions = authPolicy.LinkedToCertificates(linkedPolicy)
|
||||
} else {
|
||||
policyOptions = a.config.AuthorityConfig.Policy
|
||||
}
|
||||
|
||||
engine, err := authPolicy.New(policyOptions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// only update the policy engine when no error was returned
|
||||
a.policyEngine = engine
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isAllowed(engine authPolicy.X509Policy, sans []string) error {
|
||||
if err := engine.AreSANsAllowed(sans); err != nil {
|
||||
var policyErr *policy.NamePolicyError
|
||||
isNamePolicyError := errors.As(err, &policyErr)
|
||||
if isNamePolicyError && policyErr.Reason == policy.NotAllowed {
|
||||
return &PolicyError{
|
||||
Typ: AdminLockOut,
|
||||
Err: fmt.Errorf("the provided policy would lock out %s from the CA. Please update your policy to include %s as an allowed name", sans, sans),
|
||||
}
|
||||
}
|
||||
return &PolicyError{
|
||||
Typ: EvaluationFailure,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -0,0 +1,114 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Engine is a container for multiple policies.
|
||||
type Engine struct {
|
||||
x509Policy X509Policy
|
||||
sshUserPolicy UserPolicy
|
||||
sshHostPolicy HostPolicy
|
||||
}
|
||||
|
||||
// New returns a new Engine using Options.
|
||||
func New(options *Options) (*Engine, error) {
|
||||
|
||||
// if no options provided, return early
|
||||
if options == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var (
|
||||
x509Policy X509Policy
|
||||
sshHostPolicy HostPolicy
|
||||
sshUserPolicy UserPolicy
|
||||
err error
|
||||
)
|
||||
|
||||
// initialize the x509 allow/deny policy engine
|
||||
if x509Policy, err = NewX509PolicyEngine(options.GetX509Options()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// initialize the SSH allow/deny policy engine for host certificates
|
||||
if sshHostPolicy, err = NewSSHHostPolicyEngine(options.GetSSHOptions()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// initialize the SSH allow/deny policy engine for user certificates
|
||||
if sshUserPolicy, err = NewSSHUserPolicyEngine(options.GetSSHOptions()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Engine{
|
||||
x509Policy: x509Policy,
|
||||
sshHostPolicy: sshHostPolicy,
|
||||
sshUserPolicy: sshUserPolicy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsX509CertificateAllowed evaluates an X.509 certificate against
|
||||
// the X.509 policy (if available) and returns an error if one of the
|
||||
// names in the certificate is not allowed.
|
||||
func (e *Engine) IsX509CertificateAllowed(cert *x509.Certificate) error {
|
||||
|
||||
// return early if there's no policy to evaluate
|
||||
if e == nil || e.x509Policy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// return result of X.509 policy evaluation
|
||||
return e.x509Policy.IsX509CertificateAllowed(cert)
|
||||
}
|
||||
|
||||
// AreSANsAllowed evaluates the slice of SANs against the X.509 policy
|
||||
// (if available) and returns an error if one of the SANs is not allowed.
|
||||
func (e *Engine) AreSANsAllowed(sans []string) error {
|
||||
|
||||
// return early if there's no policy to evaluate
|
||||
if e == nil || e.x509Policy == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// return result of X.509 policy evaluation
|
||||
return e.x509Policy.AreSANsAllowed(sans)
|
||||
}
|
||||
|
||||
// IsSSHCertificateAllowed evaluates an SSH certificate against the
|
||||
// user or host policy (if configured) and returns an error if one of the
|
||||
// principals in the certificate is not allowed.
|
||||
func (e *Engine) IsSSHCertificateAllowed(cert *ssh.Certificate) error {
|
||||
|
||||
// return early if there's no policy to evaluate
|
||||
if e == nil || (e.sshHostPolicy == nil && e.sshUserPolicy == nil) {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch cert.CertType {
|
||||
case ssh.HostCert:
|
||||
// when no host policy engine is configured, but a user policy engine is
|
||||
// configured, the host certificate is denied.
|
||||
if e.sshHostPolicy == nil && e.sshUserPolicy != nil {
|
||||
return errors.New("authority not allowed to sign ssh host certificates")
|
||||
}
|
||||
|
||||
// return result of SSH host policy evaluation
|
||||
return e.sshHostPolicy.IsSSHCertificateAllowed(cert)
|
||||
case ssh.UserCert:
|
||||
// when no user policy engine is configured, but a host policy engine is
|
||||
// configured, the user certificate is denied.
|
||||
if e.sshUserPolicy == nil && e.sshHostPolicy != nil {
|
||||
return errors.New("authority not allowed to sign ssh user certificates")
|
||||
}
|
||||
|
||||
// return result of SSH user policy evaluation
|
||||
return e.sshUserPolicy.IsSSHCertificateAllowed(cert)
|
||||
default:
|
||||
return fmt.Errorf("unexpected ssh certificate type %q", cert.CertType)
|
||||
}
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
package policy
|
||||
|
||||
// Options is a container for authority level x509 and SSH
|
||||
// policy configuration.
|
||||
type Options struct {
|
||||
X509 *X509PolicyOptions `json:"x509,omitempty"`
|
||||
SSH *SSHPolicyOptions `json:"ssh,omitempty"`
|
||||
}
|
||||
|
||||
// GetX509Options returns the x509 authority level policy
|
||||
// configuration
|
||||
func (o *Options) GetX509Options() *X509PolicyOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return o.X509
|
||||
}
|
||||
|
||||
// GetSSHOptions returns the SSH authority level policy
|
||||
// configuration
|
||||
func (o *Options) GetSSHOptions() *SSHPolicyOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return o.SSH
|
||||
}
|
||||
|
||||
// X509PolicyOptionsInterface is an interface for providers
|
||||
// of x509 allowed and denied names.
|
||||
type X509PolicyOptionsInterface interface {
|
||||
GetAllowedNameOptions() *X509NameOptions
|
||||
GetDeniedNameOptions() *X509NameOptions
|
||||
AreWildcardNamesAllowed() bool
|
||||
}
|
||||
|
||||
// X509PolicyOptions is a container for x509 allowed and denied
|
||||
// names.
|
||||
type X509PolicyOptions struct {
|
||||
// AllowedNames contains the x509 allowed names
|
||||
AllowedNames *X509NameOptions `json:"allow,omitempty"`
|
||||
|
||||
// DeniedNames contains the x509 denied names
|
||||
DeniedNames *X509NameOptions `json:"deny,omitempty"`
|
||||
|
||||
// AllowWildcardNames indicates if literal wildcard names
|
||||
// like *.example.com are allowed. Defaults to false.
|
||||
AllowWildcardNames bool `json:"allowWildcardNames,omitempty"`
|
||||
}
|
||||
|
||||
// X509NameOptions models the X509 name policy configuration.
|
||||
type X509NameOptions struct {
|
||||
CommonNames []string `json:"cn,omitempty"`
|
||||
DNSDomains []string `json:"dns,omitempty"`
|
||||
IPRanges []string `json:"ip,omitempty"`
|
||||
EmailAddresses []string `json:"email,omitempty"`
|
||||
URIDomains []string `json:"uri,omitempty"`
|
||||
}
|
||||
|
||||
// HasNames checks if the AllowedNameOptions has one or more
|
||||
// names configured.
|
||||
func (o *X509NameOptions) HasNames() bool {
|
||||
return len(o.CommonNames) > 0 ||
|
||||
len(o.DNSDomains) > 0 ||
|
||||
len(o.IPRanges) > 0 ||
|
||||
len(o.EmailAddresses) > 0 ||
|
||||
len(o.URIDomains) > 0
|
||||
}
|
||||
|
||||
// GetAllowedNameOptions returns x509 allowed name policy configuration
|
||||
func (o *X509PolicyOptions) GetAllowedNameOptions() *X509NameOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return o.AllowedNames
|
||||
}
|
||||
|
||||
// GetDeniedNameOptions returns the x509 denied name policy configuration
|
||||
func (o *X509PolicyOptions) GetDeniedNameOptions() *X509NameOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return o.DeniedNames
|
||||
}
|
||||
|
||||
// AreWildcardNamesAllowed returns whether the authority allows
|
||||
// literal wildcard names to be signed.
|
||||
func (o *X509PolicyOptions) AreWildcardNamesAllowed() bool {
|
||||
if o == nil {
|
||||
return true
|
||||
}
|
||||
return o.AllowWildcardNames
|
||||
}
|
||||
|
||||
// SSHPolicyOptionsInterface is an interface for providers of
|
||||
// SSH user and host name policy configuration.
|
||||
type SSHPolicyOptionsInterface interface {
|
||||
GetAllowedUserNameOptions() *SSHNameOptions
|
||||
GetDeniedUserNameOptions() *SSHNameOptions
|
||||
GetAllowedHostNameOptions() *SSHNameOptions
|
||||
GetDeniedHostNameOptions() *SSHNameOptions
|
||||
}
|
||||
|
||||
// SSHPolicyOptions is a container for SSH user and host policy
|
||||
// configuration
|
||||
type SSHPolicyOptions struct {
|
||||
// User contains SSH user certificate options.
|
||||
User *SSHUserCertificateOptions `json:"user,omitempty"`
|
||||
// Host contains SSH host certificate options.
|
||||
Host *SSHHostCertificateOptions `json:"host,omitempty"`
|
||||
}
|
||||
|
||||
// GetAllowedUserNameOptions returns the SSH allowed user name policy
|
||||
// configuration.
|
||||
func (o *SSHPolicyOptions) GetAllowedUserNameOptions() *SSHNameOptions {
|
||||
if o == nil || o.User == nil {
|
||||
return nil
|
||||
}
|
||||
return o.User.AllowedNames
|
||||
}
|
||||
|
||||
// GetDeniedUserNameOptions returns the SSH denied user name policy
|
||||
// configuration.
|
||||
func (o *SSHPolicyOptions) GetDeniedUserNameOptions() *SSHNameOptions {
|
||||
if o == nil || o.User == nil {
|
||||
return nil
|
||||
}
|
||||
return o.User.DeniedNames
|
||||
}
|
||||
|
||||
// GetAllowedHostNameOptions returns the SSH allowed host name policy
|
||||
// configuration.
|
||||
func (o *SSHPolicyOptions) GetAllowedHostNameOptions() *SSHNameOptions {
|
||||
if o == nil || o.Host == nil {
|
||||
return nil
|
||||
}
|
||||
return o.Host.AllowedNames
|
||||
}
|
||||
|
||||
// GetDeniedHostNameOptions returns the SSH denied host name policy
|
||||
// configuration.
|
||||
func (o *SSHPolicyOptions) GetDeniedHostNameOptions() *SSHNameOptions {
|
||||
if o == nil || o.Host == nil {
|
||||
return nil
|
||||
}
|
||||
return o.Host.DeniedNames
|
||||
}
|
||||
|
||||
// SSHUserCertificateOptions is a collection of SSH user certificate options.
|
||||
type SSHUserCertificateOptions struct {
|
||||
// AllowedNames contains the names the provisioner is authorized to sign
|
||||
AllowedNames *SSHNameOptions `json:"allow,omitempty"`
|
||||
// DeniedNames contains the names the provisioner is not authorized to sign
|
||||
DeniedNames *SSHNameOptions `json:"deny,omitempty"`
|
||||
}
|
||||
|
||||
// SSHHostCertificateOptions is a collection of SSH host certificate options.
|
||||
// It's an alias of SSHUserCertificateOptions, as the options are the same
|
||||
// for both types of certificates.
|
||||
type SSHHostCertificateOptions SSHUserCertificateOptions
|
||||
|
||||
// SSHNameOptions models the SSH name policy configuration.
|
||||
type SSHNameOptions struct {
|
||||
DNSDomains []string `json:"dns,omitempty"`
|
||||
IPRanges []string `json:"ip,omitempty"`
|
||||
EmailAddresses []string `json:"email,omitempty"`
|
||||
Principals []string `json:"principal,omitempty"`
|
||||
}
|
||||
|
||||
// GetAllowedNameOptions returns the AllowedSSHNameOptions, which models the
|
||||
// names that a provisioner is authorized to sign SSH certificates for.
|
||||
func (o *SSHUserCertificateOptions) GetAllowedNameOptions() *SSHNameOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return o.AllowedNames
|
||||
}
|
||||
|
||||
// GetDeniedNameOptions returns the DeniedSSHNameOptions, which models the
|
||||
// names that a provisioner is NOT authorized to sign SSH certificates for.
|
||||
func (o *SSHUserCertificateOptions) GetDeniedNameOptions() *SSHNameOptions {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return o.DeniedNames
|
||||
}
|
||||
|
||||
// HasNames checks if the SSHNameOptions has one or more
|
||||
// names configured.
|
||||
func (o *SSHNameOptions) HasNames() bool {
|
||||
return len(o.DNSDomains) > 0 ||
|
||||
len(o.IPRanges) > 0 ||
|
||||
len(o.EmailAddresses) > 0 ||
|
||||
len(o.Principals) > 0
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestX509PolicyOptions_IsWildcardLiteralAllowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options *X509PolicyOptions
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil-options",
|
||||
options: nil,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "not-set",
|
||||
options: &X509PolicyOptions{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "set-true",
|
||||
options: &X509PolicyOptions{
|
||||
AllowWildcardNames: true,
|
||||
},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "set-false",
|
||||
options: &X509PolicyOptions{
|
||||
AllowWildcardNames: false,
|
||||
},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.options.AreWildcardNamesAllowed(); got != tt.want {
|
||||
t.Errorf("X509PolicyOptions.IsWildcardLiteralAllowed() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,256 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"go.step.sm/linkedca"
|
||||
|
||||
"github.com/smallstep/certificates/policy"
|
||||
)
|
||||
|
||||
// X509Policy is an alias for policy.X509NamePolicyEngine
|
||||
type X509Policy policy.X509NamePolicyEngine
|
||||
|
||||
// UserPolicy is an alias for policy.SSHNamePolicyEngine
|
||||
type UserPolicy policy.SSHNamePolicyEngine
|
||||
|
||||
// HostPolicy is an alias for policy.SSHNamePolicyEngine
|
||||
type HostPolicy policy.SSHNamePolicyEngine
|
||||
|
||||
// NewX509PolicyEngine creates a new x509 name policy engine
|
||||
func NewX509PolicyEngine(policyOptions X509PolicyOptionsInterface) (X509Policy, error) {
|
||||
|
||||
// return early if no policy engine options to configure
|
||||
if policyOptions == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
options := []policy.NamePolicyOption{}
|
||||
|
||||
allowed := policyOptions.GetAllowedNameOptions()
|
||||
if allowed != nil && allowed.HasNames() {
|
||||
options = append(options,
|
||||
policy.WithPermittedCommonNames(allowed.CommonNames...),
|
||||
policy.WithPermittedDNSDomains(allowed.DNSDomains...),
|
||||
policy.WithPermittedIPsOrCIDRs(allowed.IPRanges...),
|
||||
policy.WithPermittedEmailAddresses(allowed.EmailAddresses...),
|
||||
policy.WithPermittedURIDomains(allowed.URIDomains...),
|
||||
)
|
||||
}
|
||||
|
||||
denied := policyOptions.GetDeniedNameOptions()
|
||||
if denied != nil && denied.HasNames() {
|
||||
options = append(options,
|
||||
policy.WithExcludedCommonNames(denied.CommonNames...),
|
||||
policy.WithExcludedDNSDomains(denied.DNSDomains...),
|
||||
policy.WithExcludedIPsOrCIDRs(denied.IPRanges...),
|
||||
policy.WithExcludedEmailAddresses(denied.EmailAddresses...),
|
||||
policy.WithExcludedURIDomains(denied.URIDomains...),
|
||||
)
|
||||
}
|
||||
|
||||
// ensure no policy engine is returned when no name options were provided
|
||||
if len(options) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// check if configuration specifies that wildcard names are allowed
|
||||
if policyOptions.AreWildcardNamesAllowed() {
|
||||
options = append(options, policy.WithAllowLiteralWildcardNames())
|
||||
}
|
||||
|
||||
// enable subject common name verification by default
|
||||
options = append(options, policy.WithSubjectCommonNameVerification())
|
||||
|
||||
return policy.New(options...)
|
||||
}
|
||||
|
||||
type sshPolicyEngineType string
|
||||
|
||||
const (
|
||||
UserPolicyEngineType sshPolicyEngineType = "user"
|
||||
HostPolicyEngineType sshPolicyEngineType = "host"
|
||||
)
|
||||
|
||||
// newSSHUserPolicyEngine creates a new SSH user certificate policy engine
|
||||
func NewSSHUserPolicyEngine(policyOptions SSHPolicyOptionsInterface) (UserPolicy, error) {
|
||||
policyEngine, err := newSSHPolicyEngine(policyOptions, UserPolicyEngineType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policyEngine, nil
|
||||
}
|
||||
|
||||
// newSSHHostPolicyEngine create a new SSH host certificate policy engine
|
||||
func NewSSHHostPolicyEngine(policyOptions SSHPolicyOptionsInterface) (HostPolicy, error) {
|
||||
policyEngine, err := newSSHPolicyEngine(policyOptions, HostPolicyEngineType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return policyEngine, nil
|
||||
}
|
||||
|
||||
// newSSHPolicyEngine creates a new SSH name policy engine
|
||||
func newSSHPolicyEngine(policyOptions SSHPolicyOptionsInterface, typ sshPolicyEngineType) (policy.SSHNamePolicyEngine, error) {
|
||||
|
||||
// return early if no policy engine options to configure
|
||||
if policyOptions == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var (
|
||||
allowed *SSHNameOptions
|
||||
denied *SSHNameOptions
|
||||
)
|
||||
|
||||
switch typ {
|
||||
case UserPolicyEngineType:
|
||||
allowed = policyOptions.GetAllowedUserNameOptions()
|
||||
denied = policyOptions.GetDeniedUserNameOptions()
|
||||
case HostPolicyEngineType:
|
||||
allowed = policyOptions.GetAllowedHostNameOptions()
|
||||
denied = policyOptions.GetDeniedHostNameOptions()
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown SSH policy engine type %s provided", typ)
|
||||
}
|
||||
|
||||
options := []policy.NamePolicyOption{}
|
||||
|
||||
if allowed != nil && allowed.HasNames() {
|
||||
options = append(options,
|
||||
policy.WithPermittedDNSDomains(allowed.DNSDomains...),
|
||||
policy.WithPermittedIPsOrCIDRs(allowed.IPRanges...),
|
||||
policy.WithPermittedEmailAddresses(allowed.EmailAddresses...),
|
||||
policy.WithPermittedPrincipals(allowed.Principals...),
|
||||
)
|
||||
}
|
||||
|
||||
if denied != nil && denied.HasNames() {
|
||||
options = append(options,
|
||||
policy.WithExcludedDNSDomains(denied.DNSDomains...),
|
||||
policy.WithExcludedIPsOrCIDRs(denied.IPRanges...),
|
||||
policy.WithExcludedEmailAddresses(denied.EmailAddresses...),
|
||||
policy.WithExcludedPrincipals(denied.Principals...),
|
||||
)
|
||||
}
|
||||
|
||||
// ensure no policy engine is returned when no name options were provided
|
||||
if len(options) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return policy.New(options...)
|
||||
}
|
||||
|
||||
func LinkedToCertificates(p *linkedca.Policy) *Options {
|
||||
|
||||
// return early
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// return early if x509 nor SSH is set
|
||||
if p.GetX509() == nil && p.GetSsh() == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := &Options{}
|
||||
|
||||
// fill x509 policy configuration
|
||||
if x509 := p.GetX509(); x509 != nil {
|
||||
opts.X509 = &X509PolicyOptions{}
|
||||
if allow := x509.GetAllow(); allow != nil {
|
||||
opts.X509.AllowedNames = &X509NameOptions{}
|
||||
if allow.Dns != nil {
|
||||
opts.X509.AllowedNames.DNSDomains = allow.Dns
|
||||
}
|
||||
if allow.Ips != nil {
|
||||
opts.X509.AllowedNames.IPRanges = allow.Ips
|
||||
}
|
||||
if allow.Emails != nil {
|
||||
opts.X509.AllowedNames.EmailAddresses = allow.Emails
|
||||
}
|
||||
if allow.Uris != nil {
|
||||
opts.X509.AllowedNames.URIDomains = allow.Uris
|
||||
}
|
||||
if allow.CommonNames != nil {
|
||||
opts.X509.AllowedNames.CommonNames = allow.CommonNames
|
||||
}
|
||||
}
|
||||
if deny := x509.GetDeny(); deny != nil {
|
||||
opts.X509.DeniedNames = &X509NameOptions{}
|
||||
if deny.Dns != nil {
|
||||
opts.X509.DeniedNames.DNSDomains = deny.Dns
|
||||
}
|
||||
if deny.Ips != nil {
|
||||
opts.X509.DeniedNames.IPRanges = deny.Ips
|
||||
}
|
||||
if deny.Emails != nil {
|
||||
opts.X509.DeniedNames.EmailAddresses = deny.Emails
|
||||
}
|
||||
if deny.Uris != nil {
|
||||
opts.X509.DeniedNames.URIDomains = deny.Uris
|
||||
}
|
||||
if deny.CommonNames != nil {
|
||||
opts.X509.DeniedNames.CommonNames = deny.CommonNames
|
||||
}
|
||||
}
|
||||
|
||||
opts.X509.AllowWildcardNames = x509.GetAllowWildcardNames()
|
||||
}
|
||||
|
||||
// fill ssh policy configuration
|
||||
if ssh := p.GetSsh(); ssh != nil {
|
||||
opts.SSH = &SSHPolicyOptions{}
|
||||
if host := ssh.GetHost(); host != nil {
|
||||
opts.SSH.Host = &SSHHostCertificateOptions{}
|
||||
if allow := host.GetAllow(); allow != nil {
|
||||
opts.SSH.Host.AllowedNames = &SSHNameOptions{}
|
||||
if allow.Dns != nil {
|
||||
opts.SSH.Host.AllowedNames.DNSDomains = allow.Dns
|
||||
}
|
||||
if allow.Ips != nil {
|
||||
opts.SSH.Host.AllowedNames.IPRanges = allow.Ips
|
||||
}
|
||||
if allow.Principals != nil {
|
||||
opts.SSH.Host.AllowedNames.Principals = allow.Principals
|
||||
}
|
||||
}
|
||||
if deny := host.GetDeny(); deny != nil {
|
||||
opts.SSH.Host.DeniedNames = &SSHNameOptions{}
|
||||
if deny.Dns != nil {
|
||||
opts.SSH.Host.DeniedNames.DNSDomains = deny.Dns
|
||||
}
|
||||
if deny.Ips != nil {
|
||||
opts.SSH.Host.DeniedNames.IPRanges = deny.Ips
|
||||
}
|
||||
if deny.Principals != nil {
|
||||
opts.SSH.Host.DeniedNames.Principals = deny.Principals
|
||||
}
|
||||
}
|
||||
}
|
||||
if user := ssh.GetUser(); user != nil {
|
||||
opts.SSH.User = &SSHUserCertificateOptions{}
|
||||
if allow := user.GetAllow(); allow != nil {
|
||||
opts.SSH.User.AllowedNames = &SSHNameOptions{}
|
||||
if allow.Emails != nil {
|
||||
opts.SSH.User.AllowedNames.EmailAddresses = allow.Emails
|
||||
}
|
||||
if allow.Principals != nil {
|
||||
opts.SSH.User.AllowedNames.Principals = allow.Principals
|
||||
}
|
||||
}
|
||||
if deny := user.GetDeny(); deny != nil {
|
||||
opts.SSH.User.DeniedNames = &SSHNameOptions{}
|
||||
if deny.Emails != nil {
|
||||
opts.SSH.User.DeniedNames.EmailAddresses = deny.Emails
|
||||
}
|
||||
if deny.Principals != nil {
|
||||
opts.SSH.User.DeniedNames.Principals = deny.Principals
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return opts
|
||||
}
|
@ -0,0 +1,155 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
|
||||
"go.step.sm/linkedca"
|
||||
)
|
||||
|
||||
func TestPolicyToCertificates(t *testing.T) {
|
||||
type args struct {
|
||||
policy *linkedca.Policy
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *Options
|
||||
}{
|
||||
{
|
||||
name: "nil",
|
||||
args: args{
|
||||
policy: nil,
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "no-policy",
|
||||
args: args{
|
||||
&linkedca.Policy{},
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "partial-policy",
|
||||
args: args{
|
||||
&linkedca.Policy{
|
||||
X509: &linkedca.X509Policy{
|
||||
Allow: &linkedca.X509Names{
|
||||
Dns: []string{"*.local"},
|
||||
},
|
||||
AllowWildcardNames: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &Options{
|
||||
X509: &X509PolicyOptions{
|
||||
AllowedNames: &X509NameOptions{
|
||||
DNSDomains: []string{"*.local"},
|
||||
},
|
||||
AllowWildcardNames: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "full-policy",
|
||||
args: args{
|
||||
&linkedca.Policy{
|
||||
X509: &linkedca.X509Policy{
|
||||
Allow: &linkedca.X509Names{
|
||||
Dns: []string{"step"},
|
||||
Ips: []string{"127.0.0.1/24"},
|
||||
Emails: []string{"*.example.com"},
|
||||
Uris: []string{"https://*.local"},
|
||||
CommonNames: []string{"some name"},
|
||||
},
|
||||
Deny: &linkedca.X509Names{
|
||||
Dns: []string{"bad"},
|
||||
Ips: []string{"127.0.0.30"},
|
||||
Emails: []string{"badhost.example.com"},
|
||||
Uris: []string{"https://badhost.local"},
|
||||
CommonNames: []string{"another name"},
|
||||
},
|
||||
AllowWildcardNames: true,
|
||||
},
|
||||
Ssh: &linkedca.SSHPolicy{
|
||||
Host: &linkedca.SSHHostPolicy{
|
||||
Allow: &linkedca.SSHHostNames{
|
||||
Dns: []string{"*.localhost"},
|
||||
Ips: []string{"127.0.0.1/24"},
|
||||
Principals: []string{"user"},
|
||||
},
|
||||
Deny: &linkedca.SSHHostNames{
|
||||
Dns: []string{"badhost.localhost"},
|
||||
Ips: []string{"127.0.0.40"},
|
||||
Principals: []string{"root"},
|
||||
},
|
||||
},
|
||||
User: &linkedca.SSHUserPolicy{
|
||||
Allow: &linkedca.SSHUserNames{
|
||||
Emails: []string{"@work"},
|
||||
Principals: []string{"user"},
|
||||
},
|
||||
Deny: &linkedca.SSHUserNames{
|
||||
Emails: []string{"root@work"},
|
||||
Principals: []string{"root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &Options{
|
||||
X509: &X509PolicyOptions{
|
||||
AllowedNames: &X509NameOptions{
|
||||
DNSDomains: []string{"step"},
|
||||
IPRanges: []string{"127.0.0.1/24"},
|
||||
EmailAddresses: []string{"*.example.com"},
|
||||
URIDomains: []string{"https://*.local"},
|
||||
CommonNames: []string{"some name"},
|
||||
},
|
||||
DeniedNames: &X509NameOptions{
|
||||
DNSDomains: []string{"bad"},
|
||||
IPRanges: []string{"127.0.0.30"},
|
||||
EmailAddresses: []string{"badhost.example.com"},
|
||||
URIDomains: []string{"https://badhost.local"},
|
||||
CommonNames: []string{"another name"},
|
||||
},
|
||||
AllowWildcardNames: true,
|
||||
},
|
||||
SSH: &SSHPolicyOptions{
|
||||
Host: &SSHHostCertificateOptions{
|
||||
AllowedNames: &SSHNameOptions{
|
||||
DNSDomains: []string{"*.localhost"},
|
||||
IPRanges: []string{"127.0.0.1/24"},
|
||||
Principals: []string{"user"},
|
||||
},
|
||||
DeniedNames: &SSHNameOptions{
|
||||
DNSDomains: []string{"badhost.localhost"},
|
||||
IPRanges: []string{"127.0.0.40"},
|
||||
Principals: []string{"root"},
|
||||
},
|
||||
},
|
||||
User: &SSHUserCertificateOptions{
|
||||
AllowedNames: &SSHNameOptions{
|
||||
EmailAddresses: []string{"@work"},
|
||||
Principals: []string{"user"},
|
||||
},
|
||||
DeniedNames: &SSHNameOptions{
|
||||
EmailAddresses: []string{"root@work"},
|
||||
Principals: []string{"root"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := LinkedToCertificates(tt.args.policy)
|
||||
if !cmp.Equal(tt.want, got) {
|
||||
t.Errorf("policyToCertificates() diff=\n%s", cmp.Diff(tt.want, got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,65 @@
|
||||
package provisioner
|
||||
|
||||
import "github.com/smallstep/certificates/authority/policy"
|
||||
|
||||
type policyEngine struct {
|
||||
x509Policy policy.X509Policy
|
||||
sshHostPolicy policy.HostPolicy
|
||||
sshUserPolicy policy.UserPolicy
|
||||
}
|
||||
|
||||
func newPolicyEngine(options *Options) (*policyEngine, error) {
|
||||
|
||||
if options == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var (
|
||||
x509Policy policy.X509Policy
|
||||
sshHostPolicy policy.HostPolicy
|
||||
sshUserPolicy policy.UserPolicy
|
||||
err error
|
||||
)
|
||||
|
||||
// Initialize the x509 allow/deny policy engine
|
||||
if x509Policy, err = policy.NewX509PolicyEngine(options.GetX509Options()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize the SSH allow/deny policy engine for host certificates
|
||||
if sshHostPolicy, err = policy.NewSSHHostPolicyEngine(options.GetSSHOptions()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize the SSH allow/deny policy engine for user certificates
|
||||
if sshUserPolicy, err = policy.NewSSHUserPolicyEngine(options.GetSSHOptions()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &policyEngine{
|
||||
x509Policy: x509Policy,
|
||||
sshHostPolicy: sshHostPolicy,
|
||||
sshUserPolicy: sshUserPolicy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *policyEngine) getX509() policy.X509Policy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.x509Policy
|
||||
}
|
||||
|
||||
func (p *policyEngine) getSSHHost() policy.HostPolicy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.sshHostPolicy
|
||||
}
|
||||
|
||||
func (p *policyEngine) getSSHUser() policy.UserPolicy {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p.sshUserPolicy
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
|
||||
RUN apk add --no-cache curl git make
|
||||
RUN apk add --no-cache gcc musl-dev pkgconf pcsc-lite-dev
|
||||
RUN make V=1 GOFLAGS="" build
|
||||
|
||||
|
||||
FROM smallstep/step-cli:latest
|
||||
|
||||
COPY --from=builder /src/bin/step-ca /usr/local/bin/step-ca
|
||||
COPY --from=builder /src/bin/step-awskms-init /usr/local/bin/step-awskms-init
|
||||
COPY --from=builder /src/bin/step-cloudkms-init /usr/local/bin/step-cloudkms-init
|
||||
COPY --from=builder /src/bin/step-pkcs11-init /usr/local/bin/step-pkcs11-init
|
||||
COPY --from=builder /src/bin/step-yubikey-init /usr/local/bin/step-yubikey-init
|
||||
|
||||
USER root
|
||||
RUN apk add --no-cache libcap && setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/step-ca
|
||||
RUN apk add --no-cache pcsc-lite pcsc-lite-libs
|
||||
USER step
|
||||
|
||||
ENV CONFIGPATH="/home/step/config/ca.json"
|
||||
ENV PWDPATH="/home/step/secrets/password"
|
||||
|
||||
VOLUME ["/home/step"]
|
||||
STOPSIGNAL SIGTERM
|
||||
HEALTHCHECK CMD step ca health 2>/dev/null | grep "^ok" >/dev/null
|
||||
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
|
||||
CMD exec /usr/local/bin/step-ca --password-file $PWDPATH $CONFIGPATH
|
@ -0,0 +1,301 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"go.step.sm/crypto/x509util"
|
||||
)
|
||||
|
||||
type NamePolicyReason int
|
||||
|
||||
const (
|
||||
// NotAllowed results when an instance of NamePolicyEngine
|
||||
// determines that there's a constraint which doesn't permit
|
||||
// a DNS or another type of SAN to be signed (or otherwise used).
|
||||
NotAllowed NamePolicyReason = iota + 1
|
||||
// CannotParseDomain is returned when an error occurs
|
||||
// when parsing the domain part of SAN or subject.
|
||||
CannotParseDomain
|
||||
// CannotParseRFC822Name is returned when an error
|
||||
// occurs when parsing an email address.
|
||||
CannotParseRFC822Name
|
||||
// CannotMatch is the type of error returned when
|
||||
// an error happens when matching SAN types.
|
||||
CannotMatchNameToConstraint
|
||||
)
|
||||
|
||||
type NameType string
|
||||
|
||||
const (
|
||||
CNNameType NameType = "cn"
|
||||
DNSNameType NameType = "dns"
|
||||
IPNameType NameType = "ip"
|
||||
EmailNameType NameType = "email"
|
||||
URINameType NameType = "uri"
|
||||
PrincipalNameType NameType = "principal"
|
||||
)
|
||||
|
||||
type NamePolicyError struct {
|
||||
Reason NamePolicyReason
|
||||
NameType NameType
|
||||
Name string
|
||||
detail string
|
||||
}
|
||||
|
||||
func (e *NamePolicyError) Error() string {
|
||||
switch e.Reason {
|
||||
case NotAllowed:
|
||||
return fmt.Sprintf("%s name %q not allowed", e.NameType, e.Name)
|
||||
case CannotParseDomain:
|
||||
return fmt.Sprintf("cannot parse %s domain %q", e.NameType, e.Name)
|
||||
case CannotParseRFC822Name:
|
||||
return fmt.Sprintf("cannot parse %s rfc822Name %q", e.NameType, e.Name)
|
||||
case CannotMatchNameToConstraint:
|
||||
return fmt.Sprintf("error matching %s name %q to constraint", e.NameType, e.Name)
|
||||
default:
|
||||
return fmt.Sprintf("unknown error reason (%d): %s", e.Reason, e.detail)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *NamePolicyError) Detail() string {
|
||||
return e.detail
|
||||
}
|
||||
|
||||
// NamePolicyEngine can be used to check that a CSR or Certificate meets all allowed and
|
||||
// denied names before a CA creates and/or signs the Certificate.
|
||||
// TODO(hs): the X509 RFC also defines name checks on directory name; support that?
|
||||
// TODO(hs): implement Stringer interface: describe the contents of the NamePolicyEngine?
|
||||
// TODO(hs): implement matching URI schemes, paths, etc; not just the domain part of URI domains
|
||||
|
||||
type NamePolicyEngine struct {
|
||||
|
||||
// verifySubjectCommonName is set when Subject Common Name must be verified
|
||||
verifySubjectCommonName bool
|
||||
// allowLiteralWildcardNames allows literal wildcard DNS domains
|
||||
allowLiteralWildcardNames bool
|
||||
|
||||
// permitted and exluded constraints similar to x509 Name Constraints
|
||||
permittedCommonNames []string
|
||||
excludedCommonNames []string
|
||||
permittedDNSDomains []string
|
||||
excludedDNSDomains []string
|
||||
permittedIPRanges []*net.IPNet
|
||||
excludedIPRanges []*net.IPNet
|
||||
permittedEmailAddresses []string
|
||||
excludedEmailAddresses []string
|
||||
permittedURIDomains []string
|
||||
excludedURIDomains []string
|
||||
permittedPrincipals []string
|
||||
excludedPrincipals []string
|
||||
|
||||
// some internal counts for housekeeping
|
||||
numberOfCommonNameConstraints int
|
||||
numberOfDNSDomainConstraints int
|
||||
numberOfIPRangeConstraints int
|
||||
numberOfEmailAddressConstraints int
|
||||
numberOfURIDomainConstraints int
|
||||
numberOfPrincipalConstraints int
|
||||
totalNumberOfPermittedConstraints int
|
||||
totalNumberOfExcludedConstraints int
|
||||
totalNumberOfConstraints int
|
||||
}
|
||||
|
||||
// NewNamePolicyEngine creates a new NamePolicyEngine with NamePolicyOptions
|
||||
func New(opts ...NamePolicyOption) (*NamePolicyEngine, error) {
|
||||
|
||||
e := &NamePolicyEngine{}
|
||||
for _, option := range opts {
|
||||
if err := option(e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
e.permittedCommonNames = removeDuplicates(e.permittedCommonNames)
|
||||
e.permittedDNSDomains = removeDuplicates(e.permittedDNSDomains)
|
||||
e.permittedIPRanges = removeDuplicateIPNets(e.permittedIPRanges)
|
||||
e.permittedEmailAddresses = removeDuplicates(e.permittedEmailAddresses)
|
||||
e.permittedURIDomains = removeDuplicates(e.permittedURIDomains)
|
||||
e.permittedPrincipals = removeDuplicates(e.permittedPrincipals)
|
||||
|
||||
e.excludedCommonNames = removeDuplicates(e.excludedCommonNames)
|
||||
e.excludedDNSDomains = removeDuplicates(e.excludedDNSDomains)
|
||||
e.excludedIPRanges = removeDuplicateIPNets(e.excludedIPRanges)
|
||||
e.excludedEmailAddresses = removeDuplicates(e.excludedEmailAddresses)
|
||||
e.excludedURIDomains = removeDuplicates(e.excludedURIDomains)
|
||||
e.excludedPrincipals = removeDuplicates(e.excludedPrincipals)
|
||||
|
||||
e.numberOfCommonNameConstraints = len(e.permittedCommonNames) + len(e.excludedCommonNames)
|
||||
e.numberOfDNSDomainConstraints = len(e.permittedDNSDomains) + len(e.excludedDNSDomains)
|
||||
e.numberOfIPRangeConstraints = len(e.permittedIPRanges) + len(e.excludedIPRanges)
|
||||
e.numberOfEmailAddressConstraints = len(e.permittedEmailAddresses) + len(e.excludedEmailAddresses)
|
||||
e.numberOfURIDomainConstraints = len(e.permittedURIDomains) + len(e.excludedURIDomains)
|
||||
e.numberOfPrincipalConstraints = len(e.permittedPrincipals) + len(e.excludedPrincipals)
|
||||
|
||||
e.totalNumberOfPermittedConstraints = len(e.permittedCommonNames) + len(e.permittedDNSDomains) +
|
||||
len(e.permittedIPRanges) + len(e.permittedEmailAddresses) + len(e.permittedURIDomains) +
|
||||
len(e.permittedPrincipals)
|
||||
|
||||
e.totalNumberOfExcludedConstraints = len(e.excludedCommonNames) + len(e.excludedDNSDomains) +
|
||||
len(e.excludedIPRanges) + len(e.excludedEmailAddresses) + len(e.excludedURIDomains) +
|
||||
len(e.excludedPrincipals)
|
||||
|
||||
e.totalNumberOfConstraints = e.totalNumberOfPermittedConstraints + e.totalNumberOfExcludedConstraints
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// removeDuplicates returns a new slice of strings with
|
||||
// duplicate values removed. It retains the order of elements
|
||||
// in the source slice.
|
||||
func removeDuplicates(items []string) (ret []string) {
|
||||
|
||||
// no need to remove dupes; return original
|
||||
if len(items) <= 1 {
|
||||
return items
|
||||
}
|
||||
|
||||
keys := make(map[string]struct{}, len(items))
|
||||
|
||||
ret = make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if _, ok := keys[item]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
keys[item] = struct{}{}
|
||||
ret = append(ret, item)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// removeDuplicateIPNets returns a new slice of net.IPNets with
|
||||
// duplicate values removed. It retains the order of elements in
|
||||
// the source slice. An IPNet is considered duplicate if its CIDR
|
||||
// notation exists multiple times in the slice.
|
||||
func removeDuplicateIPNets(items []*net.IPNet) (ret []*net.IPNet) {
|
||||
|
||||
// no need to remove dupes; return original
|
||||
if len(items) <= 1 {
|
||||
return items
|
||||
}
|
||||
|
||||
keys := make(map[string]struct{}, len(items))
|
||||
|
||||
ret = make([]*net.IPNet, 0, len(items))
|
||||
for _, item := range items {
|
||||
key := item.String() // use CIDR notation as key
|
||||
if _, ok := keys[key]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
keys[key] = struct{}{}
|
||||
ret = append(ret, item)
|
||||
}
|
||||
|
||||
// TODO(hs): implement filter of fully overlapping ranges,
|
||||
// so that the smaller ones are automatically removed?
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// IsX509CertificateAllowed verifies that all SANs in a Certificate are allowed.
|
||||
func (e *NamePolicyEngine) IsX509CertificateAllowed(cert *x509.Certificate) error {
|
||||
if err := e.validateNames(cert.DNSNames, cert.IPAddresses, cert.EmailAddresses, cert.URIs, []string{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if e.verifySubjectCommonName {
|
||||
return e.validateCommonName(cert.Subject.CommonName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsX509CertificateRequestAllowed verifies that all names in the CSR are allowed.
|
||||
func (e *NamePolicyEngine) IsX509CertificateRequestAllowed(csr *x509.CertificateRequest) error {
|
||||
if err := e.validateNames(csr.DNSNames, csr.IPAddresses, csr.EmailAddresses, csr.URIs, []string{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if e.verifySubjectCommonName {
|
||||
return e.validateCommonName(csr.Subject.CommonName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AreSANSAllowed verifies that all names in the slice of SANs are allowed.
|
||||
// The SANs are first split into DNS names, IPs, email addresses and URIs.
|
||||
func (e *NamePolicyEngine) AreSANsAllowed(sans []string) error {
|
||||
dnsNames, ips, emails, uris := x509util.SplitSANs(sans)
|
||||
if err := e.validateNames(dnsNames, ips, emails, uris, []string{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsDNSAllowed verifies a single DNS domain is allowed.
|
||||
func (e *NamePolicyEngine) IsDNSAllowed(dns string) error {
|
||||
if err := e.validateNames([]string{dns}, []net.IP{}, []string{}, []*url.URL{}, []string{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsIPAllowed verifies a single IP domain is allowed.
|
||||
func (e *NamePolicyEngine) IsIPAllowed(ip net.IP) error {
|
||||
if err := e.validateNames([]string{}, []net.IP{ip}, []string{}, []*url.URL{}, []string{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsSSHCertificateAllowed verifies that all principals in an SSH certificate are allowed.
|
||||
func (e *NamePolicyEngine) IsSSHCertificateAllowed(cert *ssh.Certificate) error {
|
||||
dnsNames, ips, emails, principals, err := splitSSHPrincipals(cert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := e.validateNames(dnsNames, ips, emails, []*url.URL{}, principals); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitPrincipals splits SSH certificate principals into DNS names, emails and usernames.
|
||||
func splitSSHPrincipals(cert *ssh.Certificate) (dnsNames []string, ips []net.IP, emails, principals []string, err error) {
|
||||
dnsNames = []string{}
|
||||
ips = []net.IP{}
|
||||
emails = []string{}
|
||||
principals = []string{}
|
||||
var uris []*url.URL
|
||||
switch cert.CertType {
|
||||
case ssh.HostCert:
|
||||
dnsNames, ips, emails, uris = x509util.SplitSANs(cert.ValidPrincipals)
|
||||
if len(uris) > 0 {
|
||||
err = fmt.Errorf("URL principals %v not expected in SSH host certificate ", uris)
|
||||
}
|
||||
case ssh.UserCert:
|
||||
// re-using SplitSANs results in anything that can't be parsed as an IP, URI or email
|
||||
// to be considered a username principal. This allows usernames like h.slatman to be present
|
||||
// in the SSH certificate. We're exluding URIs, because they can be confusing
|
||||
// when used in a SSH user certificate.
|
||||
principals, ips, emails, uris = x509util.SplitSANs(cert.ValidPrincipals)
|
||||
if len(ips) > 0 {
|
||||
err = fmt.Errorf("IP principals %v not expected in SSH user certificate ", ips)
|
||||
}
|
||||
if len(uris) > 0 {
|
||||
err = fmt.Errorf("URL principals %v not expected in SSH user certificate ", uris)
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unexpected SSH certificate type %d", cert.CertType)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,386 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
type NamePolicyOption func(e *NamePolicyEngine) error
|
||||
|
||||
// TODO: wrap (more) errors; and prove a set of known (exported) errors
|
||||
|
||||
func WithSubjectCommonNameVerification() NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
e.verifySubjectCommonName = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithAllowLiteralWildcardNames() NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
e.allowLiteralWildcardNames = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithPermittedCommonNames(commonNames ...string) NamePolicyOption {
|
||||
return func(g *NamePolicyEngine) error {
|
||||
normalizedCommonNames := make([]string, len(commonNames))
|
||||
for i, commonName := range commonNames {
|
||||
normalizedCommonName, err := normalizeAndValidateCommonName(commonName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse permitted common name constraint %q: %w", commonName, err)
|
||||
}
|
||||
normalizedCommonNames[i] = normalizedCommonName
|
||||
}
|
||||
g.permittedCommonNames = normalizedCommonNames
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedCommonNames(commonNames ...string) NamePolicyOption {
|
||||
return func(g *NamePolicyEngine) error {
|
||||
normalizedCommonNames := make([]string, len(commonNames))
|
||||
for i, commonName := range commonNames {
|
||||
normalizedCommonName, err := normalizeAndValidateCommonName(commonName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse excluded common name constraint %q: %w", commonName, err)
|
||||
}
|
||||
normalizedCommonNames[i] = normalizedCommonName
|
||||
}
|
||||
g.excludedCommonNames = normalizedCommonNames
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithPermittedDNSDomains(domains ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
normalizedDomains := make([]string, len(domains))
|
||||
for i, domain := range domains {
|
||||
normalizedDomain, err := normalizeAndValidateDNSDomainConstraint(domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse permitted domain constraint %q: %w", domain, err)
|
||||
}
|
||||
normalizedDomains[i] = normalizedDomain
|
||||
}
|
||||
e.permittedDNSDomains = normalizedDomains
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedDNSDomains(domains ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
normalizedDomains := make([]string, len(domains))
|
||||
for i, domain := range domains {
|
||||
normalizedDomain, err := normalizeAndValidateDNSDomainConstraint(domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse excluded domain constraint %q: %w", domain, err)
|
||||
}
|
||||
normalizedDomains[i] = normalizedDomain
|
||||
}
|
||||
e.excludedDNSDomains = normalizedDomains
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithPermittedIPRanges(ipRanges ...*net.IPNet) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
e.permittedIPRanges = ipRanges
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithPermittedCIDRs(cidrs ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
networks := make([]*net.IPNet, len(cidrs))
|
||||
for i, cidr := range cidrs {
|
||||
_, nw, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse permitted CIDR constraint %q", cidr)
|
||||
}
|
||||
networks[i] = nw
|
||||
}
|
||||
e.permittedIPRanges = networks
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedCIDRs(cidrs ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
networks := make([]*net.IPNet, len(cidrs))
|
||||
for i, cidr := range cidrs {
|
||||
_, nw, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse excluded CIDR constraint %q", cidr)
|
||||
}
|
||||
networks[i] = nw
|
||||
}
|
||||
e.excludedIPRanges = networks
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithPermittedIPsOrCIDRs(ipsOrCIDRs ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
networks := make([]*net.IPNet, len(ipsOrCIDRs))
|
||||
for i, ipOrCIDR := range ipsOrCIDRs {
|
||||
_, nw, err := net.ParseCIDR(ipOrCIDR)
|
||||
if err == nil {
|
||||
networks[i] = nw
|
||||
} else if ip := net.ParseIP(ipOrCIDR); ip != nil {
|
||||
networks[i] = networkFor(ip)
|
||||
} else {
|
||||
return fmt.Errorf("cannot parse permitted constraint %q as IP nor CIDR", ipOrCIDR)
|
||||
}
|
||||
}
|
||||
e.permittedIPRanges = networks
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedIPsOrCIDRs(ipsOrCIDRs ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
networks := make([]*net.IPNet, len(ipsOrCIDRs))
|
||||
for i, ipOrCIDR := range ipsOrCIDRs {
|
||||
_, nw, err := net.ParseCIDR(ipOrCIDR)
|
||||
if err == nil {
|
||||
networks[i] = nw
|
||||
} else if ip := net.ParseIP(ipOrCIDR); ip != nil {
|
||||
networks[i] = networkFor(ip)
|
||||
} else {
|
||||
return fmt.Errorf("cannot parse excluded constraint %q as IP nor CIDR", ipOrCIDR)
|
||||
}
|
||||
}
|
||||
e.excludedIPRanges = networks
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedIPRanges(ipRanges ...*net.IPNet) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
e.excludedIPRanges = ipRanges
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithPermittedEmailAddresses(emailAddresses ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
normalizedEmailAddresses := make([]string, len(emailAddresses))
|
||||
for i, email := range emailAddresses {
|
||||
normalizedEmailAddress, err := normalizeAndValidateEmailConstraint(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse permitted email constraint %q: %w", email, err)
|
||||
}
|
||||
normalizedEmailAddresses[i] = normalizedEmailAddress
|
||||
}
|
||||
e.permittedEmailAddresses = normalizedEmailAddresses
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedEmailAddresses(emailAddresses ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
normalizedEmailAddresses := make([]string, len(emailAddresses))
|
||||
for i, email := range emailAddresses {
|
||||
normalizedEmailAddress, err := normalizeAndValidateEmailConstraint(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse excluded email constraint %q: %w", email, err)
|
||||
}
|
||||
normalizedEmailAddresses[i] = normalizedEmailAddress
|
||||
}
|
||||
e.excludedEmailAddresses = normalizedEmailAddresses
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithPermittedURIDomains(uriDomains ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
normalizedURIDomains := make([]string, len(uriDomains))
|
||||
for i, domain := range uriDomains {
|
||||
normalizedURIDomain, err := normalizeAndValidateURIDomainConstraint(domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse permitted URI domain constraint %q: %w", domain, err)
|
||||
}
|
||||
normalizedURIDomains[i] = normalizedURIDomain
|
||||
}
|
||||
e.permittedURIDomains = normalizedURIDomains
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedURIDomains(domains ...string) NamePolicyOption {
|
||||
return func(e *NamePolicyEngine) error {
|
||||
normalizedURIDomains := make([]string, len(domains))
|
||||
for i, domain := range domains {
|
||||
normalizedURIDomain, err := normalizeAndValidateURIDomainConstraint(domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot parse excluded URI domain constraint %q: %w", domain, err)
|
||||
}
|
||||
normalizedURIDomains[i] = normalizedURIDomain
|
||||
}
|
||||
e.excludedURIDomains = normalizedURIDomains
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithPermittedPrincipals(principals ...string) NamePolicyOption {
|
||||
return func(g *NamePolicyEngine) error {
|
||||
g.permittedPrincipals = principals
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedPrincipals(principals ...string) NamePolicyOption {
|
||||
return func(g *NamePolicyEngine) error {
|
||||
g.excludedPrincipals = principals
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func networkFor(ip net.IP) *net.IPNet {
|
||||
var mask net.IPMask
|
||||
if !isIPv4(ip) {
|
||||
mask = net.CIDRMask(128, 128)
|
||||
} else {
|
||||
mask = net.CIDRMask(32, 32)
|
||||
}
|
||||
nw := &net.IPNet{
|
||||
IP: ip,
|
||||
Mask: mask,
|
||||
}
|
||||
return nw
|
||||
}
|
||||
|
||||
func isIPv4(ip net.IP) bool {
|
||||
return ip.To4() != nil
|
||||
}
|
||||
|
||||
func normalizeAndValidateCommonName(constraint string) (string, error) {
|
||||
normalizedConstraint := strings.ToLower(strings.TrimSpace(constraint))
|
||||
if normalizedConstraint == "" {
|
||||
return "", fmt.Errorf("contraint %q can not be empty or white space string", constraint)
|
||||
}
|
||||
if normalizedConstraint == "*" {
|
||||
return "", fmt.Errorf("wildcard constraint %q is not supported", constraint)
|
||||
}
|
||||
return normalizedConstraint, nil
|
||||
}
|
||||
|
||||
func normalizeAndValidateDNSDomainConstraint(constraint string) (string, error) {
|
||||
normalizedConstraint := strings.ToLower(strings.TrimSpace(constraint))
|
||||
if normalizedConstraint == "" {
|
||||
return "", fmt.Errorf("contraint %q can not be empty or white space string", constraint)
|
||||
}
|
||||
if strings.Contains(normalizedConstraint, "..") {
|
||||
return "", fmt.Errorf("domain constraint %q cannot have empty labels", constraint)
|
||||
}
|
||||
if strings.HasPrefix(normalizedConstraint, ".") {
|
||||
return "", fmt.Errorf("domain constraint %q with wildcard should start with *", constraint)
|
||||
}
|
||||
if strings.LastIndex(normalizedConstraint, "*") > 0 {
|
||||
return "", fmt.Errorf("domain constraint %q can only have wildcard as starting character", constraint)
|
||||
}
|
||||
if len(normalizedConstraint) >= 2 && normalizedConstraint[0] == '*' && normalizedConstraint[1] != '.' {
|
||||
return "", fmt.Errorf("wildcard character in domain constraint %q can only be used to match (full) labels", constraint)
|
||||
}
|
||||
if strings.HasPrefix(normalizedConstraint, "*.") {
|
||||
normalizedConstraint = normalizedConstraint[1:] // cut off wildcard character; keep the period
|
||||
}
|
||||
normalizedConstraint, err := idna.Lookup.ToASCII(normalizedConstraint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("domain constraint %q can not be converted to ASCII: %w", constraint, err)
|
||||
}
|
||||
if _, ok := domainToReverseLabels(normalizedConstraint); !ok {
|
||||
return "", fmt.Errorf("cannot parse domain constraint %q", constraint)
|
||||
}
|
||||
return normalizedConstraint, nil
|
||||
}
|
||||
|
||||
func normalizeAndValidateEmailConstraint(constraint string) (string, error) {
|
||||
normalizedConstraint := strings.ToLower(strings.TrimSpace(constraint))
|
||||
if normalizedConstraint == "" {
|
||||
return "", fmt.Errorf("email contraint %q can not be empty or white space string", constraint)
|
||||
}
|
||||
if strings.Contains(normalizedConstraint, "*") {
|
||||
return "", fmt.Errorf("email constraint %q cannot contain asterisk wildcard", constraint)
|
||||
}
|
||||
if strings.Count(normalizedConstraint, "@") > 1 {
|
||||
return "", fmt.Errorf("email constraint %q contains too many @ characters", constraint)
|
||||
}
|
||||
if normalizedConstraint[0] == '@' {
|
||||
normalizedConstraint = normalizedConstraint[1:] // remove the leading @ as wildcard for emails
|
||||
}
|
||||
if normalizedConstraint[0] == '.' {
|
||||
return "", fmt.Errorf("email constraint %q cannot start with period", constraint)
|
||||
}
|
||||
if strings.Contains(normalizedConstraint, "@") {
|
||||
mailbox, ok := parseRFC2821Mailbox(normalizedConstraint)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cannot parse email constraint %q as RFC 2821 mailbox", constraint)
|
||||
}
|
||||
// According to RFC 5280, section 7.5, emails are considered to match if the local part is
|
||||
// an exact match and the host (domain) part matches the ASCII representation (case-insensitive):
|
||||
// https://datatracker.ietf.org/doc/html/rfc5280#section-7.5
|
||||
domainASCII, err := idna.Lookup.ToASCII(mailbox.domain)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("email constraint %q domain part %q cannot be converted to ASCII: %w", constraint, mailbox.domain, err)
|
||||
}
|
||||
normalizedConstraint = mailbox.local + "@" + domainASCII
|
||||
} else {
|
||||
var err error
|
||||
normalizedConstraint, err = idna.Lookup.ToASCII(normalizedConstraint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("email constraint %q cannot be converted to ASCII: %w", constraint, err)
|
||||
}
|
||||
}
|
||||
if _, ok := domainToReverseLabels(normalizedConstraint); !ok {
|
||||
return "", fmt.Errorf("cannot parse email domain constraint %q", constraint)
|
||||
}
|
||||
return normalizedConstraint, nil
|
||||
}
|
||||
|
||||
func normalizeAndValidateURIDomainConstraint(constraint string) (string, error) {
|
||||
normalizedConstraint := strings.ToLower(strings.TrimSpace(constraint))
|
||||
if normalizedConstraint == "" {
|
||||
return "", fmt.Errorf("URI domain contraint %q cannot be empty or white space string", constraint)
|
||||
}
|
||||
if strings.Contains(normalizedConstraint, "://") {
|
||||
return "", fmt.Errorf("URI domain constraint %q contains scheme (not supported yet)", constraint)
|
||||
}
|
||||
if strings.Contains(normalizedConstraint, "..") {
|
||||
return "", fmt.Errorf("URI domain constraint %q cannot have empty labels", constraint)
|
||||
}
|
||||
if strings.HasPrefix(normalizedConstraint, ".") {
|
||||
return "", fmt.Errorf("URI domain constraint %q with wildcard should start with *", constraint)
|
||||
}
|
||||
if strings.LastIndex(normalizedConstraint, "*") > 0 {
|
||||
return "", fmt.Errorf("URI domain constraint %q can only have wildcard as starting character", constraint)
|
||||
}
|
||||
if strings.HasPrefix(normalizedConstraint, "*.") {
|
||||
normalizedConstraint = normalizedConstraint[1:] // cut off wildcard character; keep the period
|
||||
}
|
||||
// we're being strict with square brackets in domains; we don't allow them, no matter what
|
||||
if strings.Contains(normalizedConstraint, "[") || strings.Contains(normalizedConstraint, "]") {
|
||||
return "", fmt.Errorf("URI domain constraint %q contains invalid square brackets", constraint)
|
||||
}
|
||||
if _, _, err := net.SplitHostPort(normalizedConstraint); err == nil {
|
||||
// a successful split (likely) with host and port; we don't currently allow ports in the config
|
||||
return "", fmt.Errorf("URI domain constraint %q cannot contain port", constraint)
|
||||
}
|
||||
// check if the host part of the URI domain constraint is an IP
|
||||
if net.ParseIP(normalizedConstraint) != nil {
|
||||
return "", fmt.Errorf("URI domain constraint %q cannot be an IP", constraint)
|
||||
}
|
||||
normalizedConstraint, err := idna.Lookup.ToASCII(normalizedConstraint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("URI domain constraint %q cannot be converted to ASCII: %w", constraint, err)
|
||||
}
|
||||
_, ok := domainToReverseLabels(normalizedConstraint)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cannot parse URI domain constraint %q", constraint)
|
||||
}
|
||||
return normalizedConstraint, nil
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
//go:build !go1.18
|
||||
// +build !go1.18
|
||||
|
||||
package policy
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_normalizeAndValidateURIDomainConstraint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
constraint string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "fail/empty-constraint",
|
||||
constraint: "",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/scheme-https",
|
||||
constraint: `https://*.local`,
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/too-many-asterisks",
|
||||
constraint: "**.local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/empty-label",
|
||||
constraint: "..local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/empty-reverse",
|
||||
constraint: ".",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/no-asterisk",
|
||||
constraint: ".example.com",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/domain-with-port",
|
||||
constraint: "host.local:8443",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/ipv4",
|
||||
constraint: "127.0.0.1",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/ipv6-brackets",
|
||||
constraint: "[::1]",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/ipv6-no-brackets",
|
||||
constraint: "::1",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/ipv6-no-brackets",
|
||||
constraint: "[::1",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/idna-internationalized-domain-name-lookup",
|
||||
constraint: `\00local`,
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ok/wildcard",
|
||||
constraint: "*.local",
|
||||
want: ".local",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ok/specific-domain",
|
||||
constraint: "example.local",
|
||||
want: "example.local",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ok/idna-internationalized-domain-name-lookup",
|
||||
constraint: `*.bücher.example.com`,
|
||||
want: ".xn--bcher-kva.example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
// IDNA2003 vs. 2008 deviation: https://unicode.org/reports/tr46/#Deviations results
|
||||
// in a difference between Go 1.18 and lower versions. Go 1.18 expects ".xn--fa-hia.de"; not .fass.de.
|
||||
name: "ok/idna-internationalized-domain-name-lookup-deviation",
|
||||
constraint: `*.faß.de`,
|
||||
want: ".fass.de",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeAndValidateURIDomainConstraint(tt.constraint)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("normalizeAndValidateURIDomainConstraint() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeAndValidateURIDomainConstraint() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
//go:build go1.18
|
||||
// +build go1.18
|
||||
|
||||
package policy
|
||||
|
||||
import "testing"
|
||||
|
||||
func Test_normalizeAndValidateURIDomainConstraint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
constraint string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "fail/empty-constraint",
|
||||
constraint: "",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/scheme-https",
|
||||
constraint: `https://*.local`,
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/too-many-asterisks",
|
||||
constraint: "**.local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/empty-label",
|
||||
constraint: "..local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/empty-reverse",
|
||||
constraint: ".",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/domain-with-port",
|
||||
constraint: "host.local:8443",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/no-asterisk",
|
||||
constraint: ".example.com",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/ipv4",
|
||||
constraint: "127.0.0.1",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/ipv6-brackets",
|
||||
constraint: "[::1]",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/ipv6-no-brackets",
|
||||
constraint: "::1",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/ipv6-no-brackets",
|
||||
constraint: "[::1",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/idna-internationalized-domain-name-lookup",
|
||||
constraint: `\00local`,
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ok/wildcard",
|
||||
constraint: "*.local",
|
||||
want: ".local",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ok/specific-domain",
|
||||
constraint: "example.local",
|
||||
want: "example.local",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ok/idna-internationalized-domain-name-lookup",
|
||||
constraint: `*.bücher.example.com`,
|
||||
want: ".xn--bcher-kva.example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
// IDNA2003 vs. 2008 deviation: https://unicode.org/reports/tr46/#Deviations results
|
||||
// in a difference between Go 1.18 and lower versions. Go 1.18 expects ".xn--fa-hia.de"; not .fass.de.
|
||||
name: "ok/idna-internationalized-domain-name-lookup-deviation",
|
||||
constraint: `*.faß.de`,
|
||||
want: ".xn--fa-hia.de",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeAndValidateURIDomainConstraint(tt.constraint)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("normalizeAndValidateURIDomainConstraint() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeAndValidateURIDomainConstraint() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,660 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_normalizeAndValidateCommonName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
constraint string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "fail/empty-constraint",
|
||||
constraint: "",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/wildcard",
|
||||
constraint: "*",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
constraint: "step",
|
||||
want: "step",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeAndValidateCommonName(tt.constraint)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("normalizeAndValidateCommonName() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeAndValidateCommonName() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_normalizeAndValidateDNSDomainConstraint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
constraint string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "fail/empty-constraint",
|
||||
constraint: "",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/wildcard-partial-label",
|
||||
constraint: "*xxxx.local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/wildcard-in-the-middle",
|
||||
constraint: "x.*.local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/empty-label",
|
||||
constraint: "..local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/empty-reverse",
|
||||
constraint: ".",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/no-asterisk",
|
||||
constraint: ".example.com",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/idna-internationalized-domain-name-lookup",
|
||||
constraint: `\00.local`, // invalid IDNA ASCII character
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ok/wildcard",
|
||||
constraint: "*.local",
|
||||
want: ".local",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ok/specific-domain",
|
||||
constraint: "example.local",
|
||||
want: "example.local",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ok/idna-internationalized-domain-name-punycode",
|
||||
constraint: "*.xn--fsq.jp", // Example value from https://www.w3.org/International/articles/idn-and-iri/
|
||||
want: ".xn--fsq.jp",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ok/idna-internationalized-domain-name-lookup-transformed",
|
||||
constraint: "*.例.jp", // Example value from https://www.w3.org/International/articles/idn-and-iri/
|
||||
want: ".xn--fsq.jp",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeAndValidateDNSDomainConstraint(tt.constraint)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("normalizeAndValidateDNSDomainConstraint() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeAndValidateDNSDomainConstraint() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_normalizeAndValidateEmailConstraint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
constraint string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "fail/empty-constraint",
|
||||
constraint: "",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/asterisk",
|
||||
constraint: "*.local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/period",
|
||||
constraint: ".local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/@period",
|
||||
constraint: "@.local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/too-many-@s",
|
||||
constraint: "@local@example.com",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/parse-mailbox",
|
||||
constraint: "mail@example.com" + string(byte(0)),
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/idna-internationalized-domain",
|
||||
constraint: "mail@xn--bla.local",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/idna-internationalized-domain-name-lookup",
|
||||
constraint: `\00local`,
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "fail/parse-domain",
|
||||
constraint: "x..example.com",
|
||||
want: "",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "ok/wildcard",
|
||||
constraint: "@local",
|
||||
want: "local",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ok/specific-mail",
|
||||
constraint: "mail@local",
|
||||
want: "mail@local",
|
||||
wantErr: false,
|
||||
},
|
||||
// TODO(hs): fix the below; doesn't get past parseRFC2821Mailbox; I think it should be allowed.
|
||||
// {
|
||||
// name: "ok/idna-internationalized-local",
|
||||
// constraint: `bücher@local`,
|
||||
// want: "bücher@local",
|
||||
// wantErr: false,
|
||||
// },
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeAndValidateEmailConstraint(tt.constraint)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("normalizeAndValidateEmailConstraint() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeAndValidateEmailConstraint() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
type test struct {
|
||||
options []NamePolicyOption
|
||||
want *NamePolicyEngine
|
||||
wantErr bool
|
||||
}
|
||||
var tests = map[string]func(t *testing.T) test{
|
||||
"fail/with-permitted-common-name": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithPermittedCommonNames("*"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-excluded-common-name": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithExcludedCommonNames(""),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-permitted-dns-domains": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithPermittedDNSDomains("**.local"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-excluded-dns-domains": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithExcludedDNSDomains("**.local"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-permitted-cidrs": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithPermittedCIDRs("127.0.0.1//24"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-excluded-cidrs": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithExcludedCIDRs("127.0.0.1//24"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-permitted-ipsOrCIDRs-cidr": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithPermittedIPsOrCIDRs("127.0.0.1//24"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-permitted-ipsOrCIDRs-ip": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithPermittedIPsOrCIDRs("127.0.0:1"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-excluded-ipsOrCIDRs-cidr": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithExcludedIPsOrCIDRs("127.0.0.1//24"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-excluded-ipsOrCIDRs-ip": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithExcludedIPsOrCIDRs("127.0.0:1"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-permitted-emails": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithPermittedEmailAddresses("*.local"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-excluded-emails": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithExcludedEmailAddresses("*.local"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-permitted-uris": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithPermittedURIDomains("**.local"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"fail/with-excluded-uris": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{
|
||||
WithExcludedURIDomains("**.local"),
|
||||
},
|
||||
want: nil,
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
"ok/default": func(t *testing.T) test {
|
||||
return test{
|
||||
options: []NamePolicyOption{},
|
||||
want: &NamePolicyEngine{},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/subject-verification": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithSubjectCommonNameVerification(),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
verifySubjectCommonName: true,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/literal-wildcards": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithAllowLiteralWildcardNames(),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
allowLiteralWildcardNames: true,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-permitted-dns-wildcard-domains": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithPermittedDNSDomains("*.local", "*.example.com"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
permittedDNSDomains: []string{".local", ".example.com"},
|
||||
numberOfDNSDomainConstraints: 2,
|
||||
totalNumberOfPermittedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-excluded-dns-domains": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithExcludedDNSDomains("*.local", "*.example.com"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
excludedDNSDomains: []string{".local", ".example.com"},
|
||||
numberOfDNSDomainConstraints: 2,
|
||||
totalNumberOfExcludedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-permitted-ip-ranges": func(t *testing.T) test {
|
||||
_, nw1, err := net.ParseCIDR("127.0.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
_, nw2, err := net.ParseCIDR("192.168.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
options := []NamePolicyOption{
|
||||
WithPermittedIPRanges(nw1, nw2),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
permittedIPRanges: []*net.IPNet{
|
||||
nw1, nw2,
|
||||
},
|
||||
numberOfIPRangeConstraints: 2,
|
||||
totalNumberOfPermittedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-excluded-ip-ranges": func(t *testing.T) test {
|
||||
_, nw1, err := net.ParseCIDR("127.0.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
_, nw2, err := net.ParseCIDR("192.168.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
options := []NamePolicyOption{
|
||||
WithExcludedIPRanges(nw1, nw2),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
excludedIPRanges: []*net.IPNet{
|
||||
nw1, nw2,
|
||||
},
|
||||
numberOfIPRangeConstraints: 2,
|
||||
totalNumberOfExcludedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-permitted-cidrs": func(t *testing.T) test {
|
||||
_, nw1, err := net.ParseCIDR("127.0.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
_, nw2, err := net.ParseCIDR("192.168.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
options := []NamePolicyOption{
|
||||
WithPermittedCIDRs("127.0.0.1/24", "192.168.0.1/24"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
permittedIPRanges: []*net.IPNet{
|
||||
nw1, nw2,
|
||||
},
|
||||
numberOfIPRangeConstraints: 2,
|
||||
totalNumberOfPermittedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-excluded-cidrs": func(t *testing.T) test {
|
||||
_, nw1, err := net.ParseCIDR("127.0.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
_, nw2, err := net.ParseCIDR("192.168.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
options := []NamePolicyOption{
|
||||
WithExcludedCIDRs("127.0.0.1/24", "192.168.0.1/24"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
excludedIPRanges: []*net.IPNet{
|
||||
nw1, nw2,
|
||||
},
|
||||
numberOfIPRangeConstraints: 2,
|
||||
totalNumberOfExcludedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-permitted-ipsOrCIDRs-cidr": func(t *testing.T) test {
|
||||
_, nw1, err := net.ParseCIDR("127.0.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
_, nw2, err := net.ParseCIDR("192.168.0.31/32")
|
||||
assert.NoError(t, err)
|
||||
_, nw3, err := net.ParseCIDR("2001:0db8:85a3:0000:0000:8a2e:0370:7334/128")
|
||||
assert.NoError(t, err)
|
||||
options := []NamePolicyOption{
|
||||
WithPermittedIPsOrCIDRs("127.0.0.1/24", "192.168.0.31", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
permittedIPRanges: []*net.IPNet{
|
||||
nw1, nw2, nw3,
|
||||
},
|
||||
numberOfIPRangeConstraints: 3,
|
||||
totalNumberOfPermittedConstraints: 3,
|
||||
totalNumberOfConstraints: 3,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-excluded-ipsOrCIDRs-cidr": func(t *testing.T) test {
|
||||
_, nw1, err := net.ParseCIDR("127.0.0.1/24")
|
||||
assert.NoError(t, err)
|
||||
_, nw2, err := net.ParseCIDR("192.168.0.31/32")
|
||||
assert.NoError(t, err)
|
||||
_, nw3, err := net.ParseCIDR("2001:0db8:85a3:0000:0000:8a2e:0370:7334/128")
|
||||
assert.NoError(t, err)
|
||||
options := []NamePolicyOption{
|
||||
WithExcludedIPsOrCIDRs("127.0.0.1/24", "192.168.0.31", "2001:0db8:85a3:0000:0000:8a2e:0370:7334"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
excludedIPRanges: []*net.IPNet{
|
||||
nw1, nw2, nw3,
|
||||
},
|
||||
numberOfIPRangeConstraints: 3,
|
||||
totalNumberOfExcludedConstraints: 3,
|
||||
totalNumberOfConstraints: 3,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-permitted-emails": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithPermittedEmailAddresses("mail@local", "@example.com"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
permittedEmailAddresses: []string{"mail@local", "example.com"},
|
||||
numberOfEmailAddressConstraints: 2,
|
||||
totalNumberOfPermittedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-excluded-emails": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithExcludedEmailAddresses("mail@local", "@example.com"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
excludedEmailAddresses: []string{"mail@local", "example.com"},
|
||||
numberOfEmailAddressConstraints: 2,
|
||||
totalNumberOfExcludedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-permitted-uris": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithPermittedURIDomains("host.local", "*.example.com"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
permittedURIDomains: []string{"host.local", ".example.com"},
|
||||
numberOfURIDomainConstraints: 2,
|
||||
totalNumberOfPermittedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-excluded-uris": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithExcludedURIDomains("host.local", "*.example.com"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
excludedURIDomains: []string{"host.local", ".example.com"},
|
||||
numberOfURIDomainConstraints: 2,
|
||||
totalNumberOfExcludedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-permitted-principals": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithPermittedPrincipals("root", "ops"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
permittedPrincipals: []string{"root", "ops"},
|
||||
numberOfPrincipalConstraints: 2,
|
||||
totalNumberOfPermittedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
"ok/with-excluded-principals": func(t *testing.T) test {
|
||||
options := []NamePolicyOption{
|
||||
WithExcludedPrincipals("root", "ops"),
|
||||
}
|
||||
return test{
|
||||
options: options,
|
||||
want: &NamePolicyEngine{
|
||||
excludedPrincipals: []string{"root", "ops"},
|
||||
numberOfPrincipalConstraints: 2,
|
||||
totalNumberOfExcludedConstraints: 2,
|
||||
totalNumberOfConstraints: 2,
|
||||
},
|
||||
wantErr: false,
|
||||
}
|
||||
},
|
||||
}
|
||||
for name, prep := range tests {
|
||||
tc := prep(t)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got, err := New(tc.options...)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("New() error = %v, wantErr %v", err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
if !cmp.Equal(tc.want, got, cmp.AllowUnexported(NamePolicyEngine{})) {
|
||||
t.Errorf("New() diff =\n %s", cmp.Diff(tc.want, got, cmp.AllowUnexported(NamePolicyEngine{})))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type SSHNamePolicyEngine interface {
|
||||
IsSSHCertificateAllowed(cert *ssh.Certificate) error
|
||||
}
|
@ -0,0 +1,647 @@
|
||||
// Copyright 2011 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
//
|
||||
// The code in this file is an adapted version of the code in
|
||||
// https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/crypto/x509/verify.go
|
||||
package policy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
|
||||
"go.step.sm/crypto/x509util"
|
||||
)
|
||||
|
||||
// validateNames verifies that all names are allowed.
|
||||
func (e *NamePolicyEngine) validateNames(dnsNames []string, ips []net.IP, emailAddresses []string, uris []*url.URL, principals []string) error {
|
||||
|
||||
// nothing to compare against; return early
|
||||
if e.totalNumberOfConstraints == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: set limit on total of all names validated? In x509 there's a limit on the number of comparisons
|
||||
// that protects the CA from a DoS (i.e. many heavy comparisons). The x509 implementation takes
|
||||
// this number as a total of all checks and keeps a (pointer to a) counter of the number of checks
|
||||
// executed so far.
|
||||
|
||||
// TODO: gather all errors, or return early? Currently we return early on the first wrong name; check might fail for multiple names.
|
||||
// Perhaps make that an option?
|
||||
for _, dns := range dnsNames {
|
||||
// if there are DNS names to check, no DNS constraints set, but there are other permitted constraints,
|
||||
// then return error, because DNS should be explicitly configured to be allowed in that case. In case there are
|
||||
// (other) excluded constraints, we'll allow a DNS (implicit allow; currently).
|
||||
if e.numberOfDNSDomainConstraints == 0 && e.totalNumberOfPermittedConstraints > 0 {
|
||||
return &NamePolicyError{
|
||||
Reason: NotAllowed,
|
||||
NameType: DNSNameType,
|
||||
Name: dns,
|
||||
detail: fmt.Sprintf("dns %q is not explicitly permitted by any constraint", dns),
|
||||
}
|
||||
}
|
||||
didCutWildcard := false
|
||||
parsedDNS := dns
|
||||
if strings.HasPrefix(parsedDNS, "*.") {
|
||||
parsedDNS = parsedDNS[1:]
|
||||
didCutWildcard = true
|
||||
}
|
||||
// TODO(hs): fix this above; we need separate rule for Subject Common Name?
|
||||
parsedDNS, err := idna.Lookup.ToASCII(parsedDNS)
|
||||
if err != nil {
|
||||
return &NamePolicyError{
|
||||
Reason: CannotParseDomain,
|
||||
NameType: DNSNameType,
|
||||
Name: dns,
|
||||
detail: fmt.Sprintf("dns %q cannot be converted to ASCII", dns),
|
||||
}
|
||||
}
|
||||
if didCutWildcard {
|
||||
parsedDNS = "*" + parsedDNS
|
||||
}
|
||||
if _, ok := domainToReverseLabels(parsedDNS); !ok { // TODO(hs): this also fails with spaces
|
||||
return &NamePolicyError{
|
||||
Reason: CannotParseDomain,
|
||||
NameType: DNSNameType,
|
||||
Name: dns,
|
||||
detail: fmt.Sprintf("cannot parse dns %q", dns),
|
||||
}
|
||||
}
|
||||
if err := checkNameConstraints(DNSNameType, dns, parsedDNS,
|
||||
func(parsedName, constraint interface{}) (bool, error) {
|
||||
return e.matchDomainConstraint(parsedName.(string), constraint.(string))
|
||||
}, e.permittedDNSDomains, e.excludedDNSDomains); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, ip := range ips {
|
||||
if e.numberOfIPRangeConstraints == 0 && e.totalNumberOfPermittedConstraints > 0 {
|
||||
return &NamePolicyError{
|
||||
Reason: NotAllowed,
|
||||
NameType: IPNameType,
|
||||
Name: ip.String(),
|
||||
detail: fmt.Sprintf("ip %q is not explicitly permitted by any constraint", ip.String()),
|
||||
}
|
||||
}
|
||||
if err := checkNameConstraints(IPNameType, ip.String(), ip,
|
||||
func(parsedName, constraint interface{}) (bool, error) {
|
||||
return matchIPConstraint(parsedName.(net.IP), constraint.(*net.IPNet))
|
||||
}, e.permittedIPRanges, e.excludedIPRanges); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, email := range emailAddresses {
|
||||
if e.numberOfEmailAddressConstraints == 0 && e.totalNumberOfPermittedConstraints > 0 {
|
||||
return &NamePolicyError{
|
||||
Reason: NotAllowed,
|
||||
NameType: EmailNameType,
|
||||
Name: email,
|
||||
detail: fmt.Sprintf("email %q is not explicitly permitted by any constraint", email),
|
||||
}
|
||||
}
|
||||
mailbox, ok := parseRFC2821Mailbox(email)
|
||||
if !ok {
|
||||
return &NamePolicyError{
|
||||
Reason: CannotParseRFC822Name,
|
||||
NameType: EmailNameType,
|
||||
Name: email,
|
||||
detail: fmt.Sprintf("invalid rfc822Name %q", mailbox),
|
||||
}
|
||||
}
|
||||
// According to RFC 5280, section 7.5, emails are considered to match if the local part is
|
||||
// an exact match and the host (domain) part matches the ASCII representation (case-insensitive):
|
||||
// https://datatracker.ietf.org/doc/html/rfc5280#section-7.5
|
||||
domainASCII, err := idna.ToASCII(mailbox.domain)
|
||||
if err != nil {
|
||||
return &NamePolicyError{
|
||||
Reason: CannotParseDomain,
|
||||
NameType: EmailNameType,
|
||||
Name: email,
|
||||
detail: fmt.Errorf("cannot parse email domain %q: %w", email, err).Error(),
|
||||
}
|
||||
}
|
||||
mailbox.domain = domainASCII
|
||||
if err := checkNameConstraints(EmailNameType, email, mailbox,
|
||||
func(parsedName, constraint interface{}) (bool, error) {
|
||||
return e.matchEmailConstraint(parsedName.(rfc2821Mailbox), constraint.(string))
|
||||
}, e.permittedEmailAddresses, e.excludedEmailAddresses); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(hs): fix internationalization for URIs (IRIs)
|
||||
|
||||
for _, uri := range uris {
|
||||
if e.numberOfURIDomainConstraints == 0 && e.totalNumberOfPermittedConstraints > 0 {
|
||||
return &NamePolicyError{
|
||||
Reason: NotAllowed,
|
||||
NameType: URINameType,
|
||||
Name: uri.String(),
|
||||
detail: fmt.Sprintf("uri %q is not explicitly permitted by any constraint", uri.String()),
|
||||
}
|
||||
}
|
||||
// TODO(hs): ideally we'd like the uri.String() to be the original contents; now
|
||||
// it's transformed into ASCII. Prevent that here?
|
||||
if err := checkNameConstraints(URINameType, uri.String(), uri,
|
||||
func(parsedName, constraint interface{}) (bool, error) {
|
||||
return e.matchURIConstraint(parsedName.(*url.URL), constraint.(string))
|
||||
}, e.permittedURIDomains, e.excludedURIDomains); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, principal := range principals {
|
||||
if e.numberOfPrincipalConstraints == 0 && e.totalNumberOfPermittedConstraints > 0 {
|
||||
return &NamePolicyError{
|
||||
Reason: NotAllowed,
|
||||
NameType: PrincipalNameType,
|
||||
Name: principal,
|
||||
detail: fmt.Sprintf("username principal %q is not explicitly permitted by any constraint", principal),
|
||||
}
|
||||
}
|
||||
// TODO: some validation? I.e. allowed characters?
|
||||
if err := checkNameConstraints(PrincipalNameType, principal, principal,
|
||||
func(parsedName, constraint interface{}) (bool, error) {
|
||||
return matchPrincipalConstraint(parsedName.(string), constraint.(string))
|
||||
}, e.permittedPrincipals, e.excludedPrincipals); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// if all checks out, all SANs are allowed
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateCommonName verifies that the Subject Common Name is allowed
|
||||
func (e *NamePolicyEngine) validateCommonName(commonName string) error {
|
||||
|
||||
// nothing to compare against; return early
|
||||
if e.totalNumberOfConstraints == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// empty common names are not validated
|
||||
if commonName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if e.numberOfCommonNameConstraints > 0 {
|
||||
// Check the Common Name using its dedicated matcher if constraints have been
|
||||
// configured. If no error is returned from matching, the Common Name was
|
||||
// explicitly allowed and nil is returned immediately.
|
||||
if err := checkNameConstraints(CNNameType, commonName, commonName,
|
||||
func(parsedName, constraint interface{}) (bool, error) {
|
||||
return matchCommonNameConstraint(parsedName.(string), constraint.(string))
|
||||
}, e.permittedCommonNames, e.excludedCommonNames); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// When an error was returned or when no constraints were configured for Common Names,
|
||||
// the Common Name should be validated against the other types of constraints too,
|
||||
// according to what type it is.
|
||||
dnsNames, ips, emails, uris := x509util.SplitSANs([]string{commonName})
|
||||
|
||||
err := e.validateNames(dnsNames, ips, emails, uris, []string{})
|
||||
|
||||
if pe, ok := err.(*NamePolicyError); ok {
|
||||
// override the name type with CN
|
||||
pe.NameType = CNNameType
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// checkNameConstraints checks that a name, of type nameType is permitted.
|
||||
// The argument parsedName contains the parsed form of name, suitable for passing
|
||||
// to the match function.
|
||||
func checkNameConstraints(
|
||||
nameType NameType,
|
||||
name string,
|
||||
parsedName interface{},
|
||||
match func(parsedName, constraint interface{}) (match bool, err error),
|
||||
permitted, excluded interface{}) error {
|
||||
|
||||
excludedValue := reflect.ValueOf(excluded)
|
||||
|
||||
for i := 0; i < excludedValue.Len(); i++ {
|
||||
constraint := excludedValue.Index(i).Interface()
|
||||
match, err := match(parsedName, constraint)
|
||||
if err != nil {
|
||||
return &NamePolicyError{
|
||||
Reason: CannotMatchNameToConstraint,
|
||||
NameType: nameType,
|
||||
Name: name,
|
||||
detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
if match {
|
||||
return &NamePolicyError{
|
||||
Reason: NotAllowed,
|
||||
NameType: nameType,
|
||||
Name: name,
|
||||
detail: fmt.Sprintf("%s %q is excluded by constraint %q", nameType, name, constraint),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
permittedValue := reflect.ValueOf(permitted)
|
||||
|
||||
ok := true
|
||||
for i := 0; i < permittedValue.Len(); i++ {
|
||||
constraint := permittedValue.Index(i).Interface()
|
||||
var err error
|
||||
if ok, err = match(parsedName, constraint); err != nil {
|
||||
return &NamePolicyError{
|
||||
Reason: CannotMatchNameToConstraint,
|
||||
NameType: nameType,
|
||||
Name: name,
|
||||
detail: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return &NamePolicyError{
|
||||
Reason: NotAllowed,
|
||||
NameType: nameType,
|
||||
Name: name,
|
||||
detail: fmt.Sprintf("%s %q is not permitted by any constraint", nameType, name),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// domainToReverseLabels converts a textual domain name like foo.example.com to
|
||||
// the list of labels in reverse order, e.g. ["com", "example", "foo"].
|
||||
func domainToReverseLabels(domain string) (reverseLabels []string, ok bool) {
|
||||
for len(domain) > 0 {
|
||||
if i := strings.LastIndexByte(domain, '.'); i == -1 {
|
||||
reverseLabels = append(reverseLabels, domain)
|
||||
domain = ""
|
||||
} else {
|
||||
reverseLabels = append(reverseLabels, domain[i+1:])
|
||||
domain = domain[:i]
|
||||
}
|
||||
}
|
||||
|
||||
if len(reverseLabels) > 0 && reverseLabels[0] == "" {
|
||||
// An empty label at the end indicates an absolute value.
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, label := range reverseLabels {
|
||||
if label == "" {
|
||||
// Empty labels are otherwise invalid.
|
||||
return nil, false
|
||||
}
|
||||
|
||||
for _, c := range label {
|
||||
if c < 33 || c > 126 {
|
||||
// Invalid character.
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return reverseLabels, true
|
||||
}
|
||||
|
||||
// rfc2821Mailbox represents a “mailbox” (which is an email address to most
|
||||
// people) by breaking it into the “local” (i.e. before the '@') and “domain”
|
||||
// parts.
|
||||
type rfc2821Mailbox struct {
|
||||
local, domain string
|
||||
}
|
||||
|
||||
// parseRFC2821Mailbox parses an email address into local and domain parts,
|
||||
// based on the ABNF for a “Mailbox” from RFC 2821. According to RFC 5280,
|
||||
// Section 4.2.1.6 that's correct for an rfc822Name from a certificate: “The
|
||||
// format of an rfc822Name is a "Mailbox" as defined in RFC 2821, Section 4.1.2”.
|
||||
func parseRFC2821Mailbox(in string) (mailbox rfc2821Mailbox, ok bool) {
|
||||
if in == "" {
|
||||
return mailbox, false
|
||||
}
|
||||
|
||||
localPartBytes := make([]byte, 0, len(in)/2)
|
||||
|
||||
if in[0] == '"' {
|
||||
// Quoted-string = DQUOTE *qcontent DQUOTE
|
||||
// non-whitespace-control = %d1-8 / %d11 / %d12 / %d14-31 / %d127
|
||||
// qcontent = qtext / quoted-pair
|
||||
// qtext = non-whitespace-control /
|
||||
// %d33 / %d35-91 / %d93-126
|
||||
// quoted-pair = ("\" text) / obs-qp
|
||||
// text = %d1-9 / %d11 / %d12 / %d14-127 / obs-text
|
||||
//
|
||||
// (Names beginning with “obs-” are the obsolete syntax from RFC 2822,
|
||||
// Section 4. Since it has been 16 years, we no longer accept that.)
|
||||
in = in[1:]
|
||||
QuotedString:
|
||||
for {
|
||||
if in == "" {
|
||||
return mailbox, false
|
||||
}
|
||||
c := in[0]
|
||||
in = in[1:]
|
||||
|
||||
switch {
|
||||
case c == '"':
|
||||
break QuotedString
|
||||
|
||||
case c == '\\':
|
||||
// quoted-pair
|
||||
if in == "" {
|
||||
return mailbox, false
|
||||
}
|
||||
if in[0] == 11 ||
|
||||
in[0] == 12 ||
|
||||
(1 <= in[0] && in[0] <= 9) ||
|
||||
(14 <= in[0] && in[0] <= 127) {
|
||||
localPartBytes = append(localPartBytes, in[0])
|
||||
in = in[1:]
|
||||
} else {
|
||||
return mailbox, false
|
||||
}
|
||||
|
||||
case c == 11 ||
|
||||
c == 12 ||
|
||||
// Space (char 32) is not allowed based on the
|
||||
// BNF, but RFC 3696 gives an example that
|
||||
// assumes that it is. Several “verified”
|
||||
// errata continue to argue about this point.
|
||||
// We choose to accept it.
|
||||
c == 32 ||
|
||||
c == 33 ||
|
||||
c == 127 ||
|
||||
(1 <= c && c <= 8) ||
|
||||
(14 <= c && c <= 31) ||
|
||||
(35 <= c && c <= 91) ||
|
||||
(93 <= c && c <= 126):
|
||||
// qtext
|
||||
localPartBytes = append(localPartBytes, c)
|
||||
|
||||
default:
|
||||
return mailbox, false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Atom ("." Atom)*
|
||||
NextChar:
|
||||
for len(in) > 0 {
|
||||
// atext from RFC 2822, Section 3.2.4
|
||||
c := in[0]
|
||||
|
||||
switch {
|
||||
case c == '\\':
|
||||
// Examples given in RFC 3696 suggest that
|
||||
// escaped characters can appear outside of a
|
||||
// quoted string. Several “verified” errata
|
||||
// continue to argue the point. We choose to
|
||||
// accept it.
|
||||
in = in[1:]
|
||||
if in == "" {
|
||||
return mailbox, false
|
||||
}
|
||||
fallthrough
|
||||
|
||||
case ('0' <= c && c <= '9') ||
|
||||
('a' <= c && c <= 'z') ||
|
||||
('A' <= c && c <= 'Z') ||
|
||||
c == '!' || c == '#' || c == '$' || c == '%' ||
|
||||
c == '&' || c == '\'' || c == '*' || c == '+' ||
|
||||
c == '-' || c == '/' || c == '=' || c == '?' ||
|
||||
c == '^' || c == '_' || c == '`' || c == '{' ||
|
||||
c == '|' || c == '}' || c == '~' || c == '.':
|
||||
localPartBytes = append(localPartBytes, in[0])
|
||||
in = in[1:]
|
||||
|
||||
default:
|
||||
break NextChar
|
||||
}
|
||||
}
|
||||
|
||||
if len(localPartBytes) == 0 {
|
||||
return mailbox, false
|
||||
}
|
||||
|
||||
// From RFC 3696, Section 3:
|
||||
// “period (".") may also appear, but may not be used to start
|
||||
// or end the local part, nor may two or more consecutive
|
||||
// periods appear.”
|
||||
twoDots := []byte{'.', '.'}
|
||||
if localPartBytes[0] == '.' ||
|
||||
localPartBytes[len(localPartBytes)-1] == '.' ||
|
||||
bytes.Contains(localPartBytes, twoDots) {
|
||||
return mailbox, false
|
||||
}
|
||||
}
|
||||
|
||||
if in == "" || in[0] != '@' {
|
||||
return mailbox, false
|
||||
}
|
||||
in = in[1:]
|
||||
|
||||
// The RFC species a format for domains, but that's known to be
|
||||
// violated in practice so we accept that anything after an '@' is the
|
||||
// domain part.
|
||||
if _, ok := domainToReverseLabels(in); !ok {
|
||||
return mailbox, false
|
||||
}
|
||||
|
||||
mailbox.local = string(localPartBytes)
|
||||
mailbox.domain = in
|
||||
return mailbox, true
|
||||
}
|
||||
|
||||
// matchDomainConstraint matches a domain against the given constraint
|
||||
func (e *NamePolicyEngine) matchDomainConstraint(domain, constraint string) (bool, error) {
|
||||
// The meaning of zero length constraints is not specified, but this
|
||||
// code follows NSS and accepts them as matching everything.
|
||||
if constraint == "" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// A single whitespace seems to be considered a valid domain, but we don't allow it.
|
||||
if domain == " " {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Block domains that start with just a period
|
||||
if domain[0] == '.' {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Block wildcard domains that don't start with exactly "*." (i.e. double wildcards and such)
|
||||
if domain[0] == '*' && domain[1] != '.' {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Check if the domain starts with a wildcard and return early if not allowed
|
||||
if strings.HasPrefix(domain, "*.") && !e.allowLiteralWildcardNames {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Only allow asterisk at the start of the domain; we don't allow them as part of a domain label or as a (sub)domain label (currently)
|
||||
if strings.LastIndex(domain, "*") > 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Don't allow constraints with empty labels in any position
|
||||
if strings.Contains(constraint, "..") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
domainLabels, ok := domainToReverseLabels(domain)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("cannot parse domain %q", domain)
|
||||
}
|
||||
|
||||
// RFC 5280 says that a leading period in a domain name means that at
|
||||
// least one label must be prepended, but only for URI and email
|
||||
// constraints, not DNS constraints. The code also supports that
|
||||
// behavior for DNS constraints. In our adaptation of the original
|
||||
// Go stdlib x509 Name Constraint implementation we look for exactly
|
||||
// one subdomain, currently.
|
||||
|
||||
mustHaveSubdomains := false
|
||||
if constraint[0] == '.' {
|
||||
mustHaveSubdomains = true
|
||||
constraint = constraint[1:]
|
||||
}
|
||||
|
||||
constraintLabels, ok := domainToReverseLabels(constraint)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("cannot parse domain constraint %q", constraint)
|
||||
}
|
||||
|
||||
expectedNumberOfLabels := len(constraintLabels)
|
||||
if mustHaveSubdomains {
|
||||
// we expect exactly one more label if it starts with the "canonical" x509 "wildcard": "."
|
||||
// in the future we could extend this to support multiple additional labels and/or more
|
||||
// complex matching.
|
||||
expectedNumberOfLabels++
|
||||
}
|
||||
|
||||
if len(domainLabels) != expectedNumberOfLabels {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for i, constraintLabel := range constraintLabels {
|
||||
if !strings.EqualFold(constraintLabel, domainLabels[i]) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// SOURCE: https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/crypto/x509/verify.go
|
||||
func matchIPConstraint(ip net.IP, constraint *net.IPNet) (bool, error) {
|
||||
|
||||
// TODO(hs): this is code from Go library, but I got some unexpected result:
|
||||
// with permitted net 127.0.0.0/24, 127.0.0.1 is NOT allowed. When parsing 127.0.0.1 as net.IP
|
||||
// which is in the IPAddresses slice, the underlying length is 16. The contraint.IP has a length
|
||||
// of 4 instead. I currently don't believe that this is a bug in Go now, but why is it like that?
|
||||
// Is there a difference because we're not operating on a sans []string slice? Or is the Go
|
||||
// implementation stricter regarding IPv4 vs. IPv6? I've been bitten by some unfortunate differences
|
||||
// between the two before (i.e. IPv4 in IPv6; IP SANS in ACME)
|
||||
// if len(ip) != len(constraint.IP) {
|
||||
// return false, nil
|
||||
// }
|
||||
|
||||
// for i := range ip {
|
||||
// if mask := constraint.Mask[i]; ip[i]&mask != constraint.IP[i]&mask {
|
||||
// return false, nil
|
||||
// }
|
||||
// }
|
||||
|
||||
contained := constraint.Contains(ip) // TODO(hs): validate that this is the correct behavior; also check IPv4-in-IPv6 (again)
|
||||
|
||||
return contained, nil
|
||||
}
|
||||
|
||||
// SOURCE: https://cs.opensource.google/go/go/+/refs/tags/go1.17.5:src/crypto/x509/verify.go
|
||||
func (e *NamePolicyEngine) matchEmailConstraint(mailbox rfc2821Mailbox, constraint string) (bool, error) {
|
||||
if strings.Contains(constraint, "@") {
|
||||
constraintMailbox, ok := parseRFC2821Mailbox(constraint)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("cannot parse constraint %q", constraint)
|
||||
}
|
||||
return mailbox.local == constraintMailbox.local && strings.EqualFold(mailbox.domain, constraintMailbox.domain), nil
|
||||
}
|
||||
|
||||
// Otherwise the constraint is like a DNS constraint of the domain part
|
||||
// of the mailbox.
|
||||
return e.matchDomainConstraint(mailbox.domain, constraint)
|
||||
}
|
||||
|
||||
// matchURIConstraint matches an URL against a constraint
|
||||
func (e *NamePolicyEngine) matchURIConstraint(uri *url.URL, constraint string) (bool, error) {
|
||||
// From RFC 5280, Section 4.2.1.10:
|
||||
// “a uniformResourceIdentifier that does not include an authority
|
||||
// component with a host name specified as a fully qualified domain
|
||||
// name (e.g., if the URI either does not include an authority
|
||||
// component or includes an authority component in which the host name
|
||||
// is specified as an IP address), then the application MUST reject the
|
||||
// certificate.”
|
||||
|
||||
host := uri.Host
|
||||
if host == "" {
|
||||
return false, fmt.Errorf("URI with empty host (%q) cannot be matched against constraints", uri.String())
|
||||
}
|
||||
|
||||
// Block hosts with the wildcard character; no exceptions, also not when wildcards allowed.
|
||||
if strings.Contains(host, "*") {
|
||||
return false, fmt.Errorf("URI host %q cannot contain asterisk", uri.String())
|
||||
}
|
||||
|
||||
if strings.Contains(host, ":") && !strings.HasSuffix(host, "]") {
|
||||
var err error
|
||||
host, _, err = net.SplitHostPort(host)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") ||
|
||||
net.ParseIP(host) != nil {
|
||||
return false, fmt.Errorf("URI with IP %q cannot be matched against constraints", uri.String())
|
||||
}
|
||||
|
||||
// TODO(hs): add checks for scheme, path, etc.; either here, or in a different constraint matcher (to keep this one simple)
|
||||
|
||||
return e.matchDomainConstraint(host, constraint)
|
||||
}
|
||||
|
||||
// matchPrincipalConstraint performs a string literal equality check against a constraint.
|
||||
func matchPrincipalConstraint(principal, constraint string) (bool, error) {
|
||||
// allow any plain principal when wildcard constraint is used
|
||||
if constraint == "*" {
|
||||
return true, nil
|
||||
}
|
||||
return strings.EqualFold(principal, constraint), nil
|
||||
}
|
||||
|
||||
// matchCommonNameConstraint performs a string literal equality check against constraint.
|
||||
func matchCommonNameConstraint(commonName, constraint string) (bool, error) {
|
||||
// wildcard constraint is (currently) not supported for common names
|
||||
if constraint == "*" {
|
||||
return false, nil
|
||||
}
|
||||
return strings.EqualFold(commonName, constraint), nil
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"net"
|
||||
)
|
||||
|
||||
type X509NamePolicyEngine interface {
|
||||
IsX509CertificateAllowed(cert *x509.Certificate) error
|
||||
IsX509CertificateRequestAllowed(csr *x509.CertificateRequest) error
|
||||
AreSANsAllowed(sans []string) error
|
||||
IsDNSAllowed(dns string) error
|
||||
IsIPAllowed(ip net.IP) error
|
||||
}
|
Loading…
Reference in New Issue