Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add explicit cn_validations field to PKI Roles #15996

Merged
merged 4 commits into from
Jun 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 122 additions & 98 deletions builtin/logical/pki/backend_test.go

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions builtin/logical/pki/ca_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func (b *backend) getGenerationParams(ctx context.Context, storage logical.Stora
StreetAddress: data.Get("street_address").([]string),
PostalCode: data.Get("postal_code").([]string),
NotBeforeDuration: time.Duration(data.Get("not_before_duration").(int)) * time.Second,
CNValidations: []string{"disabled"},
}
*role.AllowWildcardCertificates = true

Expand Down
50 changes: 49 additions & 1 deletion builtin/logical/pki/cert_util.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,54 @@ func validateURISAN(b *backend, data *inputBundle, uri string) bool {
return valid
}

// Validates a given common name, ensuring its either a email or a hostname
// after validating it according to the role parameters, or disables
// validation altogether.
func validateCommonName(b *backend, data *inputBundle, name string) string {
isDisabled := len(data.role.CNValidations) == 1 && data.role.CNValidations[0] == "disabled"
if isDisabled {
return ""
}

if validateNames(b, data, []string{name}) != "" {
return name
}

// Validations weren't disabled, but the role lacked CN Validations, so
// don't restrict types. This case is hit in certain existing tests.
if len(data.role.CNValidations) == 0 {
return ""
}

// If there's an at in the data, ensure email type validation is allowed.
// Otherwise, ensure hostname is allowed.
if strings.Contains(name, "@") {
var allowsEmails bool
for _, validation := range data.role.CNValidations {
if validation == "email" {
allowsEmails = true
break
}
}
if !allowsEmails {
return name
}
} else {
var allowsHostnames bool
for _, validation := range data.role.CNValidations {
if validation == "hostname" {
allowsHostnames = true
break
}
}
if !allowsHostnames {
return name
}
}

return ""
}

// Given a set of requested names for a certificate, verifies that all of them
// match the various toggles set in the role for controlling issuance.
// If one does not pass, it is returned in the string argument.
Expand Down Expand Up @@ -1049,7 +1097,7 @@ func generateCreationBundle(b *backend, data *inputBundle, caSign *certutil.CAIn
// Check the CN. This ensures that the CN is checked even if it's
// excluded from SANs.
if cn != "" {
badName := validateNames(b, data, []string{cn})
badName := validateCommonName(b, data, cn)
if len(badName) != 0 {
return nil, errutil.UserError{Err: fmt.Sprintf(
"common name %s not allowed by this role", badName)}
Expand Down
1 change: 1 addition & 0 deletions builtin/logical/pki/path_issue_sign.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ func (b *backend) pathSignVerbatim(ctx context.Context, req *logical.Request, da
AllowedOtherSANs: []string{"*"},
AllowedSerialNumbers: []string{"*"},
AllowedURISANs: []string{"*"},
CNValidations: []string{"disabled"},
GenerateLease: new(bool),
KeyUsage: data.Get("key_usage").([]string),
ExtKeyUsage: data.Get("ext_key_usage").([]string),
Expand Down
84 changes: 84 additions & 0 deletions builtin/logical/pki/path_roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/certutil"
"github.com/hashicorp/vault/sdk/helper/consts"
"github.com/hashicorp/vault/sdk/helper/errutil"
"github.com/hashicorp/vault/sdk/logical"
)

Expand Down Expand Up @@ -384,6 +385,21 @@ for "generate_lease".`,
},
},

"cn_validations": {
Type: framework.TypeCommaStringSlice,
Default: []string{"email", "hostname"},
Description: `List of allowed validations to run against the
Common Name field. Values can include 'email' to validate the CN is a email
address, 'hostname' to validate the CN is a valid hostname (potentially
including wildcards). When multiple validations are specified, these take
OR semantics (either email OR hostname are allowed). The special value
'disabled' allows disabling all CN name validations, allowing for arbitrary
non-Hostname, non-Email address CNs.`,
DisplayAttrs: &framework.DisplayAttributes{
Name: "Common Name Validations",
},
},

"policy_identifiers": {
Type: framework.TypeCommaStringSlice,
Description: `A comma-separated string or list of policy OIDs, or a JSON list of qualified policy
Expand Down Expand Up @@ -565,6 +581,18 @@ func (b *backend) getRole(ctx context.Context, s logical.Storage, n string) (*ro
modified = true
}

// Update CN Validations to be the present default, "email,hostname"
if len(result.CNValidations) == 0 {
result.CNValidations = []string{"email", "hostname"}
modified = true
}

// Ensure the role is valida fter updating.
_, err = validateRole(b, &result, ctx, s)
if err != nil {
return nil, err
}

if modified && (b.System().LocalMount() || !b.System().ReplicationState().HasState(consts.ReplicationPerformanceSecondary)) {
jsonEntry, err := logical.StorageEntryJSON("role/"+n, &result)
if err != nil {
Expand Down Expand Up @@ -660,6 +688,7 @@ func (b *backend) pathRoleCreate(ctx context.Context, req *logical.Request, data
GenerateLease: new(bool),
NoStore: data.Get("no_store").(bool),
RequireCN: data.Get("require_cn").(bool),
CNValidations: data.Get("cn_validations").([]string),
AllowedSerialNumbers: data.Get("allowed_serial_numbers").([]string),
PolicyIdentifiers: getPolicyIdentifier(data, nil),
BasicConstraintsValidForNonCA: data.Get("basic_constraints_valid_for_non_ca").(bool),
Expand Down Expand Up @@ -781,6 +810,12 @@ func validateRole(b *backend, entry *roleEntry, ctx context.Context, s logical.S

}

// Ensures CNValidations are alright
entry.CNValidations, err = checkCNValidations(entry.CNValidations)
if err != nil {
return nil, errutil.UserError{Err: err.Error()}
}

return resp, nil
}

Expand Down Expand Up @@ -848,6 +883,7 @@ func (b *backend) pathRolePatch(ctx context.Context, req *logical.Request, data
GenerateLease: new(bool),
NoStore: getWithExplicitDefault(data, "no_store", oldEntry.NoStore).(bool),
RequireCN: getWithExplicitDefault(data, "require_cn", oldEntry.RequireCN).(bool),
CNValidations: getWithExplicitDefault(data, "cn_validations", oldEntry.CNValidations).([]string),
AllowedSerialNumbers: getWithExplicitDefault(data, "allowed_serial_numbers", oldEntry.AllowedSerialNumbers).([]string),
PolicyIdentifiers: getPolicyIdentifier(data, &oldEntry.PolicyIdentifiers),
BasicConstraintsValidForNonCA: getWithExplicitDefault(data, "basic_constraints_valid_for_non_ca", oldEntry.BasicConstraintsValidForNonCA).(bool),
Expand Down Expand Up @@ -1043,6 +1079,7 @@ type roleEntry struct {
GenerateLease *bool `json:"generate_lease,omitempty"`
NoStore bool `json:"no_store" mapstructure:"no_store"`
RequireCN bool `json:"require_cn" mapstructure:"require_cn"`
CNValidations []string `json:"cn_validations" mapstructure:"cn_validations"`
AllowedOtherSANs []string `json:"allowed_other_sans" mapstructure:"allowed_other_sans"`
AllowedSerialNumbers []string `json:"allowed_serial_numbers" mapstructure:"allowed_serial_numbers"`
AllowedURISANs []string `json:"allowed_uri_sans" mapstructure:"allowed_uri_sans"`
Expand Down Expand Up @@ -1095,6 +1132,7 @@ func (r *roleEntry) ToResponseData() map[string]interface{} {
"allowed_serial_numbers": r.AllowedSerialNumbers,
"allowed_uri_sans": r.AllowedURISANs,
"require_cn": r.RequireCN,
"cn_validations": r.CNValidations,
"policy_identifiers": r.PolicyIdentifiers,
"basic_constraints_valid_for_non_ca": r.BasicConstraintsValidForNonCA,
"not_before_duration": int64(r.NotBeforeDuration.Seconds()),
Expand All @@ -1110,6 +1148,52 @@ func (r *roleEntry) ToResponseData() map[string]interface{} {
return responseData
}

func checkCNValidations(validations []string) ([]string, error) {
var haveDisabled bool
var haveEmail bool
var haveHostname bool

var result []string

if len(validations) == 0 {
return []string{"email", "hostname"}, nil
}

for _, validation := range validations {
switch strings.ToLower(validation) {
case "disabled":
if haveDisabled {
return nil, fmt.Errorf("cn_validations value incorrect: `disabled` specified multiple times")
}
haveDisabled = true
case "email":
if haveEmail {
return nil, fmt.Errorf("cn_validations value incorrect: `email` specified multiple times")
}
haveEmail = true
case "hostname":
if haveHostname {
return nil, fmt.Errorf("cn_validations value incorrect: `hostname` specified multiple times")
}
haveHostname = true
default:
return nil, fmt.Errorf("cn_validations value incorrect: unknown type: `%s`", validation)
}

result = append(result, strings.ToLower(validation))
}

if !haveDisabled && !haveEmail && !haveHostname {
return nil, fmt.Errorf("cn_validations value incorrect: must specify a value (`email` and/or `hostname`) or `disabled`")
}

if haveDisabled && (haveEmail || haveHostname) {
return nil, fmt.Errorf("cn_validations value incorrect: cannot specify `disabled` along with `email` or `hostname`")
}

return result, nil
}

const pathListRolesHelpSyn = `List the existing roles in this backend`

const pathListRolesHelpDesc = `Roles will be listed by the role name.`
Expand Down
1 change: 1 addition & 0 deletions builtin/logical/pki/path_root.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ func (b *backend) pathIssuerSignIntermediate(ctx context.Context, req *logical.R
AllowedURISANs: []string{"*"},
NotAfter: data.Get("not_after").(string),
NotBeforeDuration: time.Duration(data.Get("not_before_duration").(int)) * time.Second,
CNValidations: []string{"disabled"},
}
*role.AllowWildcardCertificates = true

Expand Down
3 changes: 3 additions & 0 deletions changelog/15996.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
secret/pki: Allow issuing certificates with non-domain, non-email Common Names from roles, sign-verbatim, and as issuers (`cn_validations`).
```
13 changes: 13 additions & 0 deletions website/content/api-docs/secret/pki.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2419,6 +2419,19 @@ request is denied.
`YYYY-MM-ddTHH:MM:SSZ`. Supports the Y10K end date for IEEE 802.1AR-2018
standard devices, `9999-12-31T23:59:59Z`.

- `cn_validations` `(list: ["email", "hostname"])` - Validations to run on the
Common Name field of the certificate. Valid values include:

- `email`, to ensure the Common Name is an email address (contains an `@` sign),
- `hostname`, to ensure the Common Name is a hostname (otherwise).

Multiple values can be separated with a comma or specified as a list and use
OR semantics (either email or hostname in the CN are allowed). When the
special value "disabled" is used (must be specified alone), none of the usual
validation is run (including but not limited to `allowed_domains` and basic
correctness validation around email addresses and domain names). This allows
non-standard CNs to be used verbatim from the request.

#### Sample Payload

```json
Expand Down