mirror of
https://github.com/hashicorp/vault.git
synced 2025-09-01 12:01:10 +02:00
* add gosimport to make fmt and run it * move installation to tools.sh * correct weird spacing issue * Update Makefile Co-authored-by: Nick Cabatoff <ncabatoff@hashicorp.com> * fix a weird issue --------- Co-authored-by: Nick Cabatoff <ncabatoff@hashicorp.com>
1023 lines
33 KiB
Go
1023 lines
33 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package issuing
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/go-secure-stdlib/strutil"
|
|
"github.com/hashicorp/vault/builtin/logical/pki/parsing"
|
|
"github.com/hashicorp/vault/builtin/logical/pki/pki_backend"
|
|
"github.com/hashicorp/vault/sdk/framework"
|
|
"github.com/hashicorp/vault/sdk/helper/certutil"
|
|
"github.com/hashicorp/vault/sdk/helper/errutil"
|
|
"github.com/hashicorp/vault/sdk/logical"
|
|
"github.com/ryanuber/go-glob"
|
|
"golang.org/x/net/idna"
|
|
)
|
|
|
|
var (
|
|
// labelRegex is a single label from a valid domain name and was extracted
|
|
// from hostnameRegex below for use in leftWildLabelRegex, without any
|
|
// label separators (`.`).
|
|
labelRegex = `([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])`
|
|
|
|
// A note on hostnameRegex: although we set the StrictDomainName option
|
|
// when doing the idna conversion, this appears to only affect output, not
|
|
// input, so it will allow e.g. host^123.example.com straight through. So
|
|
// we still need to use this to check the output.
|
|
hostnameRegex = regexp.MustCompile(`^(\*\.)?(` + labelRegex + `\.)*` + labelRegex + `\.?$`)
|
|
|
|
// Left Wildcard Label Regex is equivalent to a single domain label
|
|
// component from hostnameRegex above, but with additional wildcard
|
|
// characters added. There are four possibilities here:
|
|
//
|
|
// 1. Entire label is a wildcard,
|
|
// 2. Wildcard exists at the start,
|
|
// 3. Wildcard exists at the end,
|
|
// 4. Wildcard exists in the middle.
|
|
allWildRegex = `\*`
|
|
startWildRegex = `\*` + labelRegex
|
|
endWildRegex = labelRegex + `\*`
|
|
middleWildRegex = labelRegex + `\*` + labelRegex
|
|
leftWildLabelRegex = regexp.MustCompile(`^(` + allWildRegex + `|` + startWildRegex + `|` + endWildRegex + `|` + middleWildRegex + `)$`)
|
|
)
|
|
|
|
type EntityInfo struct {
|
|
DisplayName string
|
|
EntityID string
|
|
}
|
|
|
|
func NewEntityInfoFromReq(req *logical.Request) EntityInfo {
|
|
if req == nil {
|
|
return EntityInfo{}
|
|
}
|
|
return EntityInfo{
|
|
DisplayName: req.DisplayName,
|
|
EntityID: req.EntityID,
|
|
}
|
|
}
|
|
|
|
type CreationBundleInput interface {
|
|
CertNotAfterInput
|
|
GetCommonName() string
|
|
GetSerialNumber() string
|
|
GetExcludeCnFromSans() bool
|
|
GetOptionalAltNames() (interface{}, bool)
|
|
GetOtherSans() []string
|
|
GetIpSans() []string
|
|
GetURISans() []string
|
|
GetOptionalSkid() (interface{}, bool)
|
|
IsUserIdInSchema() (interface{}, bool)
|
|
GetUserIds() []string
|
|
}
|
|
|
|
// GenerateCreationBundle is a shared function that reads parameters supplied
|
|
// from the various endpoints and generates a CreationParameters with the
|
|
// parameters that can be used to issue or sign
|
|
func GenerateCreationBundle(b logical.SystemView, role *RoleEntry, entityInfo EntityInfo, cb CreationBundleInput, caSign *certutil.CAInfoBundle, csr *x509.CertificateRequest) (*certutil.CreationBundle, []string, error) {
|
|
// Read in names -- CN, DNS and email addresses
|
|
var cn string
|
|
var ridSerialNumber string
|
|
var warnings []string
|
|
dnsNames := []string{}
|
|
emailAddresses := []string{}
|
|
{
|
|
if csr != nil && role.UseCSRCommonName {
|
|
cn = csr.Subject.CommonName
|
|
}
|
|
if cn == "" {
|
|
cn = cb.GetCommonName()
|
|
if cn == "" && role.RequireCN {
|
|
return nil, nil, errutil.UserError{Err: `the common_name field is required, or must be provided in a CSR with "use_csr_common_name" set to true, unless "require_cn" is set to false`}
|
|
}
|
|
}
|
|
|
|
ridSerialNumber = cb.GetSerialNumber()
|
|
|
|
// only take serial number from CSR if one was not supplied via API
|
|
if ridSerialNumber == "" && csr != nil {
|
|
ridSerialNumber = csr.Subject.SerialNumber
|
|
}
|
|
|
|
if csr != nil && role.UseCSRSANs {
|
|
dnsNames = csr.DNSNames
|
|
emailAddresses = csr.EmailAddresses
|
|
}
|
|
|
|
if cn != "" && !cb.GetExcludeCnFromSans() {
|
|
if strings.Contains(cn, "@") {
|
|
// Note: emails are not disallowed if the role's email protection
|
|
// flag is false, because they may well be included for
|
|
// informational purposes; it is up to the verifying party to
|
|
// ensure that email addresses in a subject alternate name can be
|
|
// used for the purpose for which they are presented
|
|
emailAddresses = append(emailAddresses, cn)
|
|
} else {
|
|
// Only add to dnsNames if it's actually a DNS name but convert
|
|
// idn first
|
|
p := idna.New(
|
|
idna.StrictDomainName(true),
|
|
idna.VerifyDNSLength(true),
|
|
)
|
|
converted, err := p.ToASCII(cn)
|
|
if err != nil {
|
|
return nil, nil, errutil.UserError{Err: err.Error()}
|
|
}
|
|
if hostnameRegex.MatchString(converted) {
|
|
dnsNames = append(dnsNames, converted)
|
|
}
|
|
}
|
|
}
|
|
|
|
if csr == nil || !role.UseCSRSANs {
|
|
cnAltRaw, ok := cb.GetOptionalAltNames()
|
|
if ok {
|
|
cnAlt := strutil.ParseDedupAndSortStrings(cnAltRaw.(string), ",")
|
|
for _, v := range cnAlt {
|
|
if strings.Contains(v, "@") {
|
|
emailAddresses = append(emailAddresses, v)
|
|
} else {
|
|
// Only add to dnsNames if it's actually a DNS name but
|
|
// convert idn first
|
|
p := idna.New(
|
|
idna.StrictDomainName(true),
|
|
idna.VerifyDNSLength(true),
|
|
)
|
|
converted, err := p.ToASCII(v)
|
|
if err != nil {
|
|
return nil, nil, errutil.UserError{Err: err.Error()}
|
|
}
|
|
if hostnameRegex.MatchString(converted) {
|
|
dnsNames = append(dnsNames, converted)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check the CN. This ensures that the CN is checked even if it's
|
|
// excluded from SANs.
|
|
if cn != "" {
|
|
badName := ValidateCommonName(b, role, entityInfo, cn)
|
|
if len(badName) != 0 {
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf(
|
|
"common name %s not allowed by this role", badName)}
|
|
}
|
|
}
|
|
|
|
if ridSerialNumber != "" {
|
|
badName := ValidateSerialNumber(role, ridSerialNumber)
|
|
if len(badName) != 0 {
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf(
|
|
"serial_number %s not allowed by this role", badName)}
|
|
}
|
|
}
|
|
|
|
// Check for bad email and/or DNS names
|
|
badName := ValidateNames(b, role, entityInfo, dnsNames)
|
|
if len(badName) != 0 {
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf(
|
|
"subject alternate name %s not allowed by this role", badName)}
|
|
}
|
|
|
|
badName = ValidateNames(b, role, entityInfo, emailAddresses)
|
|
if len(badName) != 0 {
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf(
|
|
"email address %s not allowed by this role", badName)}
|
|
}
|
|
}
|
|
|
|
// otherSANsInput has the same format as the other_sans HTTP param in the
|
|
// Vault PKI API: it is a list of strings of the form <oid>;<type>:<value>
|
|
// where <type> must be UTF8/UTF-8.
|
|
var otherSANsInput []string
|
|
// otherSANs is the output of parseOtherSANs(otherSANsInput): its keys are
|
|
// the <oid> value, its values are of the form [<type>, <value>]
|
|
var otherSANs map[string][]string
|
|
if sans := cb.GetOtherSans(); len(sans) > 0 {
|
|
otherSANsInput = sans
|
|
}
|
|
if role.UseCSRSANs && csr != nil && len(csr.Extensions) > 0 {
|
|
others, err := certutil.GetOtherSANsFromX509Extensions(csr.Extensions)
|
|
if err != nil {
|
|
return nil, nil, errutil.UserError{Err: fmt.Errorf("could not parse requested other SAN: %w", err).Error()}
|
|
}
|
|
for _, other := range others {
|
|
otherSANsInput = append(otherSANsInput, other.String())
|
|
}
|
|
}
|
|
if len(otherSANsInput) > 0 {
|
|
requested, err := ParseOtherSANs(otherSANsInput)
|
|
if err != nil {
|
|
return nil, nil, errutil.UserError{Err: fmt.Errorf("could not parse requested other SAN: %w", err).Error()}
|
|
}
|
|
badOID, badName, err := ValidateOtherSANs(role, requested)
|
|
switch {
|
|
case err != nil:
|
|
return nil, nil, errutil.UserError{Err: err.Error()}
|
|
case len(badName) > 0:
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf(
|
|
"other SAN %s not allowed for OID %s by this role", badName, badOID)}
|
|
case len(badOID) > 0:
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf(
|
|
"other SAN OID %s not allowed by this role", badOID)}
|
|
default:
|
|
otherSANs = requested
|
|
}
|
|
}
|
|
|
|
// Get and verify any IP SANs
|
|
ipAddresses := []net.IP{}
|
|
{
|
|
if csr != nil && role.UseCSRSANs {
|
|
if len(csr.IPAddresses) > 0 {
|
|
if !role.AllowIPSANs {
|
|
return nil, nil, errutil.UserError{Err: "IP Subject Alternative Names are not allowed in this role, but was provided some via CSR"}
|
|
}
|
|
ipAddresses = csr.IPAddresses
|
|
}
|
|
} else {
|
|
ipAlt := cb.GetIpSans()
|
|
if len(ipAlt) > 0 {
|
|
if !role.AllowIPSANs {
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf(
|
|
"IP Subject Alternative Names are not allowed in this role, but was provided %s", ipAlt)}
|
|
}
|
|
for _, v := range ipAlt {
|
|
parsedIP := net.ParseIP(v)
|
|
if parsedIP == nil {
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf(
|
|
"the value %q is not a valid IP address", v)}
|
|
}
|
|
ipAddresses = append(ipAddresses, parsedIP)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
URIs := []*url.URL{}
|
|
{
|
|
if csr != nil && role.UseCSRSANs {
|
|
if len(csr.URIs) > 0 {
|
|
if len(role.AllowedURISANs) == 0 {
|
|
return nil, nil, errutil.UserError{
|
|
Err: "URI Subject Alternative Names are not allowed in this role, but were provided via CSR",
|
|
}
|
|
}
|
|
|
|
// validate uri sans
|
|
for _, uri := range csr.URIs {
|
|
valid := ValidateURISAN(b, role, entityInfo, uri.String())
|
|
if !valid {
|
|
return nil, nil, errutil.UserError{
|
|
Err: "URI Subject Alternative Names were provided via CSR which are not valid for this role",
|
|
}
|
|
}
|
|
|
|
URIs = append(URIs, uri)
|
|
}
|
|
}
|
|
} else {
|
|
uriAlt := cb.GetURISans()
|
|
if len(uriAlt) > 0 {
|
|
if len(role.AllowedURISANs) == 0 {
|
|
return nil, nil, errutil.UserError{
|
|
Err: "URI Subject Alternative Names are not allowed in this role, but were provided via the API",
|
|
}
|
|
}
|
|
|
|
for _, uri := range uriAlt {
|
|
valid := ValidateURISAN(b, role, entityInfo, uri)
|
|
if !valid {
|
|
return nil, nil, errutil.UserError{
|
|
Err: "URI Subject Alternative Names were provided via the API which are not valid for this role",
|
|
}
|
|
}
|
|
|
|
parsedURI, err := url.Parse(uri)
|
|
if parsedURI == nil || err != nil {
|
|
return nil, nil, errutil.UserError{
|
|
Err: fmt.Sprintf(
|
|
"the provided URI Subject Alternative Name %q is not a valid URI", uri),
|
|
}
|
|
}
|
|
|
|
URIs = append(URIs, parsedURI)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Most of these could also be RemoveDuplicateStable, or even
|
|
// leave duplicates in, but OU is the one most likely to be duplicated.
|
|
subject := pkix.Name{
|
|
CommonName: cn,
|
|
SerialNumber: ridSerialNumber,
|
|
Country: strutil.RemoveDuplicatesStable(role.Country, false),
|
|
Organization: strutil.RemoveDuplicatesStable(role.Organization, false),
|
|
OrganizationalUnit: strutil.RemoveDuplicatesStable(role.OU, false),
|
|
Locality: strutil.RemoveDuplicatesStable(role.Locality, false),
|
|
Province: strutil.RemoveDuplicatesStable(role.Province, false),
|
|
StreetAddress: strutil.RemoveDuplicatesStable(role.StreetAddress, false),
|
|
PostalCode: strutil.RemoveDuplicatesStable(role.PostalCode, false),
|
|
}
|
|
|
|
// Get the TTL and verify it against the max allowed
|
|
notAfter, ttlWarnings, err := GetCertificateNotAfter(b, role, cb, caSign)
|
|
if err != nil {
|
|
return nil, warnings, err
|
|
}
|
|
warnings = append(warnings, ttlWarnings...)
|
|
|
|
// Parse SKID from the request for cross-signing.
|
|
var skid []byte
|
|
{
|
|
if rawSKIDValue, ok := cb.GetOptionalSkid(); ok {
|
|
// Handle removing common separators to make copy/paste from tool
|
|
// output easier. Chromium uses space, OpenSSL uses colons, and at
|
|
// one point, Vault had preferred dash as a separator for hex
|
|
// strings.
|
|
var err error
|
|
skidValue := rawSKIDValue.(string)
|
|
for _, separator := range []string{":", "-", " "} {
|
|
skidValue = strings.ReplaceAll(skidValue, separator, "")
|
|
}
|
|
|
|
skid, err = hex.DecodeString(skidValue)
|
|
if err != nil {
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf("cannot parse requested SKID value as hex: %v", err)}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add UserIDs into the Subject, if the request type supports it.
|
|
if _, present := cb.IsUserIdInSchema(); present {
|
|
rawUserIDs := cb.GetUserIds()
|
|
|
|
// Only take UserIDs from CSR if one was not supplied via API.
|
|
if len(rawUserIDs) == 0 && csr != nil {
|
|
for _, attr := range csr.Subject.Names {
|
|
if attr.Type.Equal(certutil.SubjectPilotUserIDAttributeOID) {
|
|
switch aValue := attr.Value.(type) {
|
|
case string:
|
|
rawUserIDs = append(rawUserIDs, aValue)
|
|
case []byte:
|
|
rawUserIDs = append(rawUserIDs, string(aValue))
|
|
default:
|
|
return nil, nil, errutil.UserError{Err: "unknown type for user_id attribute in CSR's Subject"}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for bad userIDs and add to the subject.
|
|
if len(rawUserIDs) > 0 {
|
|
for _, value := range rawUserIDs {
|
|
if !ValidateUserId(role, value) {
|
|
return nil, nil, errutil.UserError{Err: fmt.Sprintf("user_id %v is not allowed by this role", value)}
|
|
}
|
|
|
|
subject.ExtraNames = append(subject.ExtraNames, pkix.AttributeTypeAndValue{
|
|
Type: certutil.SubjectPilotUserIDAttributeOID,
|
|
Value: value,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
creation := &certutil.CreationBundle{
|
|
Params: &certutil.CreationParameters{
|
|
Subject: subject,
|
|
DNSNames: strutil.RemoveDuplicates(dnsNames, false),
|
|
EmailAddresses: strutil.RemoveDuplicates(emailAddresses, false),
|
|
IPAddresses: ipAddresses,
|
|
URIs: URIs,
|
|
OtherSANs: otherSANs,
|
|
KeyType: role.KeyType,
|
|
KeyBits: role.KeyBits,
|
|
SignatureBits: role.SignatureBits,
|
|
UsePSS: role.UsePSS,
|
|
NotAfter: notAfter,
|
|
KeyUsage: x509.KeyUsage(parsing.ParseKeyUsages(role.KeyUsage)),
|
|
ExtKeyUsage: ParseExtKeyUsagesFromRole(role),
|
|
ExtKeyUsageOIDs: role.ExtKeyUsageOIDs,
|
|
PolicyIdentifiers: role.PolicyIdentifiers,
|
|
BasicConstraintsValidForNonCA: role.BasicConstraintsValidForNonCA,
|
|
NotBeforeDuration: role.NotBeforeDuration,
|
|
ForceAppendCaChain: caSign != nil,
|
|
SKID: skid,
|
|
},
|
|
SigningBundle: caSign,
|
|
CSR: csr,
|
|
}
|
|
|
|
// Don't deal with URLs or max path length if it's self-signed, as these
|
|
// normally come from the signing bundle
|
|
if caSign == nil {
|
|
return creation, warnings, nil
|
|
}
|
|
|
|
// This will have been read in from the getGlobalAIAURLs function
|
|
creation.Params.URLs = caSign.URLs
|
|
|
|
// If the max path length in the role is not nil, it was specified at
|
|
// generation time with the max_path_length parameter; otherwise derive it
|
|
// from the signing certificate
|
|
if role.MaxPathLength != nil {
|
|
creation.Params.MaxPathLength = *role.MaxPathLength
|
|
} else {
|
|
switch {
|
|
case caSign.Certificate.MaxPathLen < 0:
|
|
creation.Params.MaxPathLength = -1
|
|
case caSign.Certificate.MaxPathLen == 0 &&
|
|
caSign.Certificate.MaxPathLenZero:
|
|
// The signing function will ensure that we do not issue a CA cert
|
|
creation.Params.MaxPathLength = 0
|
|
default:
|
|
// If this takes it to zero, we handle this case later if
|
|
// necessary
|
|
creation.Params.MaxPathLength = caSign.Certificate.MaxPathLen - 1
|
|
}
|
|
}
|
|
|
|
return creation, warnings, nil
|
|
}
|
|
|
|
// 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.
|
|
func ValidateNames(b logical.SystemView, role *RoleEntry, entityInfo EntityInfo, names []string) string {
|
|
for _, name := range names {
|
|
// Previously, reducedName was called sanitizedName but this made
|
|
// little sense under the previous interpretation of wildcards,
|
|
// leading to two bugs in this implementation. We presently call it
|
|
// "reduced" to indicate that it is still untrusted input (potentially
|
|
// different from the bare Common Name entry we're validating), it
|
|
// might have been modified such as by the removal of wildcard labels
|
|
// or the email prefix.
|
|
reducedName := name
|
|
emailDomain := reducedName
|
|
wildcardLabel := ""
|
|
isEmail := false
|
|
isWildcard := false
|
|
|
|
// If it has an @, assume it is an email address and separate out the
|
|
// user from the hostname portion so that we can act on the hostname.
|
|
// Note that this matches behavior from the alt_names parameter. If it
|
|
// ends up being problematic for users, I guess that could be separated
|
|
// into dns_names and email_names in the future to be explicit, but I
|
|
// don't think this is likely.
|
|
if strings.Contains(reducedName, "@") {
|
|
splitEmail := strings.Split(reducedName, "@")
|
|
if len(splitEmail) != 2 {
|
|
return name
|
|
}
|
|
reducedName = splitEmail[1]
|
|
emailDomain = splitEmail[1]
|
|
isEmail = true
|
|
}
|
|
|
|
if IsWildcardDomain(reducedName) {
|
|
// Regardless of later rejections below, this common name contains
|
|
// a wildcard character and is thus technically a wildcard name.
|
|
isWildcard = true
|
|
|
|
// Additionally, if AllowWildcardCertificates is explicitly
|
|
// forbidden, it takes precedence over AllowAnyName, thus we should
|
|
// reject the name now.
|
|
//
|
|
// We expect the role to have been correctly migrated but guard for
|
|
// safety.
|
|
if role.AllowWildcardCertificates != nil && !*role.AllowWildcardCertificates {
|
|
return name
|
|
}
|
|
|
|
// Check that this domain is well-formatted per RFC 6125.
|
|
var err error
|
|
wildcardLabel, reducedName, err = ValidateWildcardDomain(reducedName)
|
|
if err != nil {
|
|
return name
|
|
}
|
|
}
|
|
|
|
// Email addresses using wildcard domain names do not make sense
|
|
// in a Common Name field.
|
|
if isEmail && isWildcard {
|
|
return name
|
|
}
|
|
|
|
// AllowAnyName is checked after this because EnforceHostnames still
|
|
// applies when allowing any name. Also, we check the reduced name to
|
|
// ensure that we are not either checking a full email address or a
|
|
// wildcard prefix.
|
|
if role.EnforceHostnames {
|
|
if reducedName != "" {
|
|
// See note above about splitLabels having only one segment
|
|
// and setting reducedName to the empty string.
|
|
p := idna.New(
|
|
idna.StrictDomainName(true),
|
|
idna.VerifyDNSLength(true),
|
|
)
|
|
converted, err := p.ToASCII(reducedName)
|
|
if err != nil {
|
|
return name
|
|
}
|
|
if !hostnameRegex.MatchString(converted) {
|
|
return name
|
|
}
|
|
}
|
|
|
|
// When a wildcard is specified, we additionally need to validate
|
|
// the label with the wildcard is correctly formed.
|
|
if isWildcard && !leftWildLabelRegex.MatchString(wildcardLabel) {
|
|
return name
|
|
}
|
|
}
|
|
|
|
// Self-explanatory, but validations from EnforceHostnames and
|
|
// AllowWildcardCertificates take precedence.
|
|
if role.AllowAnyName {
|
|
continue
|
|
}
|
|
|
|
// The following blocks all work the same basic way:
|
|
// 1) If a role allows a certain class of base (localhost, token
|
|
// display name, role-configured domains), perform further tests
|
|
//
|
|
// 2) If there is a perfect match on either the sanitized name or it's an
|
|
// email address with a perfect match on the hostname portion, allow it
|
|
//
|
|
// 3) If subdomains are allowed, we check based on the sanitized name;
|
|
// note that if not a wildcard, will be equivalent to the email domain
|
|
// for email checks, and we already checked above for both a wildcard
|
|
// and email address being present in the same name
|
|
// 3a) First we check for a non-wildcard subdomain, as in <name>.<base>
|
|
// 3b) Then we check if it's a wildcard and the base domain is a match
|
|
//
|
|
// Variances are noted in-line
|
|
|
|
if role.AllowLocalhost {
|
|
if reducedName == "localhost" ||
|
|
reducedName == "localdomain" ||
|
|
(isEmail && emailDomain == "localhost") ||
|
|
(isEmail && emailDomain == "localdomain") {
|
|
continue
|
|
}
|
|
|
|
if role.AllowSubdomains {
|
|
// It is possible, if unlikely, to have a subdomain of "localhost"
|
|
if strings.HasSuffix(reducedName, ".localhost") ||
|
|
(isWildcard && reducedName == "localhost") {
|
|
continue
|
|
}
|
|
|
|
// A subdomain of "localdomain" is also not entirely uncommon
|
|
if strings.HasSuffix(reducedName, ".localdomain") ||
|
|
(isWildcard && reducedName == "localdomain") {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if role.AllowTokenDisplayName {
|
|
if name == entityInfo.DisplayName {
|
|
continue
|
|
}
|
|
|
|
if role.AllowSubdomains {
|
|
if isEmail {
|
|
// If it's an email address, we need to parse the token
|
|
// display name in order to do a proper comparison of the
|
|
// subdomain
|
|
if strings.Contains(entityInfo.DisplayName, "@") {
|
|
splitDisplay := strings.Split(entityInfo.DisplayName, "@")
|
|
if len(splitDisplay) == 2 {
|
|
// Compare the sanitized name against the hostname
|
|
// portion of the email address in the broken
|
|
// display name
|
|
if strings.HasSuffix(reducedName, "."+splitDisplay[1]) {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.HasSuffix(reducedName, "."+entityInfo.DisplayName) ||
|
|
(isWildcard && reducedName == entityInfo.DisplayName) {
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(role.AllowedDomains) > 0 {
|
|
valid := false
|
|
for _, currDomain := range role.AllowedDomains {
|
|
// If there is, say, a trailing comma, ignore it
|
|
if currDomain == "" {
|
|
continue
|
|
}
|
|
|
|
if role.AllowedDomainsTemplate {
|
|
isTemplate, _ := framework.ValidateIdentityTemplate(currDomain)
|
|
if isTemplate && entityInfo.EntityID != "" {
|
|
tmpCurrDomain, err := framework.PopulateIdentityTemplate(currDomain, entityInfo.EntityID, b)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
currDomain = tmpCurrDomain
|
|
}
|
|
}
|
|
|
|
// First, allow an exact match of the base domain if that role flag
|
|
// is enabled
|
|
if role.AllowBareDomains &&
|
|
(strings.EqualFold(name, currDomain) ||
|
|
(isEmail && strings.EqualFold(emailDomain, currDomain))) {
|
|
valid = true
|
|
break
|
|
}
|
|
|
|
if role.AllowSubdomains {
|
|
if strings.HasSuffix(reducedName, "."+currDomain) ||
|
|
(isWildcard && strings.EqualFold(reducedName, currDomain)) {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if role.AllowGlobDomains &&
|
|
strings.Contains(currDomain, "*") &&
|
|
glob.Glob(strings.ToLower(currDomain), strings.ToLower(name)) {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if valid {
|
|
continue
|
|
}
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func IsWildcardDomain(name string) bool {
|
|
// Per RFC 6125 Section 6.4.3, and explicitly contradicting the earlier
|
|
// RFC 2818 which no modern client will validate against, there are two
|
|
// main types of wildcards, each with a single wildcard specifier (`*`,
|
|
// functionally different from the `*` used as a glob from the
|
|
// AllowGlobDomains parsing path) in the left-most label:
|
|
//
|
|
// 1. Entire label is a single wildcard character (most common and
|
|
// well-supported),
|
|
// 2. Part of the label contains a single wildcard character (e.g. per
|
|
// RFC 6125: baz*.example.net, *baz.example.net, or b*z.example.net).
|
|
//
|
|
// We permit issuance of both but not the older RFC 2818 style under
|
|
// the new AllowWildcardCertificates option. However, anything with a
|
|
// glob character is technically a wildcard, though not a valid one.
|
|
|
|
return strings.Contains(name, "*")
|
|
}
|
|
|
|
func ValidateWildcardDomain(name string) (string, string, error) {
|
|
// See note in isWildcardDomain(...) about the definition of a wildcard
|
|
// domain.
|
|
var wildcardLabel string
|
|
var reducedName string
|
|
|
|
if strings.Count(name, "*") > 1 {
|
|
// As mentioned above, only one wildcard character is permitted
|
|
// under RFC 6125 semantics.
|
|
return wildcardLabel, reducedName, fmt.Errorf("expected only one wildcard identifier in the given domain name")
|
|
}
|
|
|
|
// Split the Common Name into two parts: a left-most label and the
|
|
// remaining segments (if present).
|
|
splitLabels := strings.SplitN(name, ".", 2)
|
|
if len(splitLabels) != 2 {
|
|
// We've been given a single-part domain name that consists
|
|
// entirely of a wildcard. This is a little tricky to handle,
|
|
// but EnforceHostnames validates both the wildcard-containing
|
|
// label and the reduced name, but _only_ the latter if it is
|
|
// non-empty. This allows us to still validate the only label
|
|
// component matches hostname expectations still.
|
|
wildcardLabel = splitLabels[0]
|
|
reducedName = ""
|
|
} else {
|
|
// We have a (at least) two label domain name. But before we can
|
|
// update our names, we need to validate the wildcard ended up
|
|
// in the segment we expected it to. While this is (kinda)
|
|
// validated under EnforceHostnames's leftWildLabelRegex, we
|
|
// still need to validate it in the non-enforced mode.
|
|
//
|
|
// By validated assumption above, we know there's strictly one
|
|
// wildcard in this domain so we only need to check the wildcard
|
|
// label or the reduced name (as one is equivalent to the other).
|
|
// Because we later assume reducedName _lacks_ wildcard segments,
|
|
// we validate that.
|
|
wildcardLabel = splitLabels[0]
|
|
reducedName = splitLabels[1]
|
|
if strings.Contains(reducedName, "*") {
|
|
return wildcardLabel, reducedName, fmt.Errorf("expected wildcard to only be present in left-most domain label")
|
|
}
|
|
}
|
|
|
|
return wildcardLabel, reducedName, nil
|
|
}
|
|
|
|
// ValidateCommonName Validates a given common name, ensuring it's either an email or a hostname
|
|
// after validating it according to the role parameters, or disables
|
|
// validation altogether.
|
|
func ValidateCommonName(b logical.SystemView, role *RoleEntry, entityInfo EntityInfo, name string) string {
|
|
isDisabled := len(role.CNValidations) == 1 && role.CNValidations[0] == "disabled"
|
|
if isDisabled {
|
|
return ""
|
|
}
|
|
|
|
if ValidateNames(b, role, entityInfo, []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(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 role.CNValidations {
|
|
if validation == "email" {
|
|
allowsEmails = true
|
|
break
|
|
}
|
|
}
|
|
if !allowsEmails {
|
|
return name
|
|
}
|
|
} else {
|
|
var allowsHostnames bool
|
|
for _, validation := range role.CNValidations {
|
|
if validation == "hostname" {
|
|
allowsHostnames = true
|
|
break
|
|
}
|
|
}
|
|
if !allowsHostnames {
|
|
return name
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ValidateOtherSANs checks if the values requested are allowed. If an OID
|
|
// isn't allowed, it will be returned as the first string. If a Value isn't
|
|
// allowed, it will be returned as the second string. Empty strings + error
|
|
// means everything is okay.
|
|
func ValidateOtherSANs(role *RoleEntry, requested map[string][]string) (string, string, error) {
|
|
if len(role.AllowedOtherSANs) == 1 && role.AllowedOtherSANs[0] == "*" {
|
|
// Anything is allowed
|
|
return "", "", nil
|
|
}
|
|
|
|
allowed, err := ParseOtherSANs(role.AllowedOtherSANs)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("error parsing role's allowed SANs: %w", err)
|
|
}
|
|
for oid, names := range requested {
|
|
for _, name := range names {
|
|
allowedNames, ok := allowed[oid]
|
|
if !ok {
|
|
return oid, "", nil
|
|
}
|
|
|
|
valid := false
|
|
for _, allowedName := range allowedNames {
|
|
if glob.Glob(allowedName, name) {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !valid {
|
|
return oid, name, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", "", nil
|
|
}
|
|
|
|
func ParseOtherSANs(others []string) (map[string][]string, error) {
|
|
result := map[string][]string{}
|
|
for _, other := range others {
|
|
splitOther := strings.SplitN(other, ";", 2)
|
|
if len(splitOther) != 2 {
|
|
return nil, fmt.Errorf("expected a semicolon in other SAN %q", other)
|
|
}
|
|
splitType := strings.SplitN(splitOther[1], ":", 2)
|
|
if len(splitType) != 2 {
|
|
return nil, fmt.Errorf("expected a colon in other SAN %q", other)
|
|
}
|
|
switch {
|
|
case strings.EqualFold(splitType[0], "utf8"):
|
|
case strings.EqualFold(splitType[0], "utf-8"):
|
|
default:
|
|
return nil, fmt.Errorf("only utf8 other SANs are supported; found non-supported type in other SAN %q", other)
|
|
}
|
|
result[splitOther[0]] = append(result[splitOther[0]], splitType[1])
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// Given a URI SAN, verify that it is allowed.
|
|
func ValidateURISAN(b logical.SystemView, role *RoleEntry, entityInfo EntityInfo, uri string) bool {
|
|
valid := false
|
|
for _, allowed := range role.AllowedURISANs {
|
|
if role.AllowedURISANsTemplate {
|
|
isTemplate, _ := framework.ValidateIdentityTemplate(allowed)
|
|
if isTemplate && entityInfo.EntityID != "" {
|
|
tmpAllowed, err := framework.PopulateIdentityTemplate(allowed, entityInfo.EntityID, b)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
allowed = tmpAllowed
|
|
}
|
|
}
|
|
validURI := glob.Glob(allowed, uri)
|
|
if validURI {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
return valid
|
|
}
|
|
|
|
// ValidateUserId Returns bool stating whether the given UserId is Valid
|
|
func ValidateUserId(role *RoleEntry, userId string) bool {
|
|
allowedList := role.AllowedUserIDs
|
|
|
|
if len(allowedList) == 0 {
|
|
// Nothing is allowed.
|
|
return false
|
|
}
|
|
|
|
if strutil.StrListContainsCaseInsensitive(allowedList, userId) {
|
|
return true
|
|
}
|
|
|
|
for _, rolePattern := range allowedList {
|
|
if rolePattern == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.Contains(rolePattern, "*") && glob.Glob(rolePattern, userId) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// No matches.
|
|
return false
|
|
}
|
|
|
|
func ValidateSerialNumber(role *RoleEntry, serialNumber string) string {
|
|
valid := false
|
|
if len(role.AllowedSerialNumbers) > 0 {
|
|
for _, currSerialNumber := range role.AllowedSerialNumbers {
|
|
if currSerialNumber == "" {
|
|
continue
|
|
}
|
|
|
|
if (strings.Contains(currSerialNumber, "*") &&
|
|
glob.Glob(currSerialNumber, serialNumber)) ||
|
|
currSerialNumber == serialNumber {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !valid {
|
|
return serialNumber
|
|
} else {
|
|
return ""
|
|
}
|
|
}
|
|
|
|
type CertNotAfterInput interface {
|
|
GetTTL() int
|
|
GetOptionalNotAfter() (interface{}, bool)
|
|
}
|
|
|
|
// GetCertificateNotAfter compute a certificate's NotAfter date based on the mount ttl, role, signing bundle and input
|
|
// api data being sent. Returns a NotAfter time, a set of warnings or an error.
|
|
func GetCertificateNotAfter(b logical.SystemView, role *RoleEntry, input CertNotAfterInput, caSign *certutil.CAInfoBundle) (time.Time, []string, error) {
|
|
var warnings []string
|
|
var maxTTL time.Duration
|
|
var notAfter time.Time
|
|
var err error
|
|
|
|
ttl := time.Duration(input.GetTTL()) * time.Second
|
|
notAfterAlt := role.NotAfter
|
|
if notAfterAlt == "" {
|
|
notAfterAltRaw, ok := input.GetOptionalNotAfter()
|
|
if ok {
|
|
notAfterAlt = notAfterAltRaw.(string)
|
|
}
|
|
}
|
|
if ttl > 0 && notAfterAlt != "" {
|
|
return time.Time{}, warnings, errutil.UserError{Err: "Either ttl or not_after should be provided. Both should not be provided in the same request."}
|
|
}
|
|
|
|
if ttl == 0 && role.TTL > 0 {
|
|
ttl = role.TTL
|
|
}
|
|
|
|
if role.MaxTTL > 0 {
|
|
maxTTL = role.MaxTTL
|
|
}
|
|
|
|
if ttl == 0 {
|
|
ttl = b.DefaultLeaseTTL()
|
|
}
|
|
if maxTTL == 0 {
|
|
maxTTL = b.MaxLeaseTTL()
|
|
}
|
|
if ttl > maxTTL {
|
|
warnings = append(warnings, fmt.Sprintf("TTL %q is longer than permitted maxTTL %q, so maxTTL is being used", ttl, maxTTL))
|
|
ttl = maxTTL
|
|
}
|
|
|
|
if notAfterAlt != "" {
|
|
notAfter, err = time.Parse(time.RFC3339, notAfterAlt)
|
|
if err != nil {
|
|
return notAfter, warnings, errutil.UserError{Err: err.Error()}
|
|
}
|
|
} else {
|
|
notAfter = time.Now().Add(ttl)
|
|
}
|
|
notAfter, err = ApplyIssuerLeafNotAfterBehavior(caSign, notAfter)
|
|
if err != nil {
|
|
return time.Time{}, warnings, err
|
|
}
|
|
return notAfter, warnings, nil
|
|
}
|
|
|
|
// ApplyIssuerLeafNotAfterBehavior resets a certificate's notAfter time or errors out based on the
|
|
// issuer's notAfter date along with the LeafNotAfterBehavior configuration
|
|
func ApplyIssuerLeafNotAfterBehavior(caSign *certutil.CAInfoBundle, notAfter time.Time) (time.Time, error) {
|
|
if caSign != nil && notAfter.After(caSign.Certificate.NotAfter) {
|
|
// If it's not self-signed, verify that the issued certificate
|
|
// won't be valid past the lifetime of the CA certificate, and
|
|
// act accordingly. This is dependent based on the issuer's
|
|
// LeafNotAfterBehavior argument.
|
|
switch caSign.LeafNotAfterBehavior {
|
|
case certutil.PermitNotAfterBehavior:
|
|
// Explicitly do nothing.
|
|
case certutil.TruncateNotAfterBehavior:
|
|
notAfter = caSign.Certificate.NotAfter
|
|
case certutil.ErrNotAfterBehavior:
|
|
fallthrough
|
|
default:
|
|
return time.Time{}, errutil.UserError{Err: fmt.Sprintf(
|
|
"cannot satisfy request, as TTL would result in notAfter of %s that is beyond the expiration of the CA certificate at %s", notAfter.UTC().Format(time.RFC3339Nano), caSign.Certificate.NotAfter.UTC().Format(time.RFC3339Nano))}
|
|
}
|
|
}
|
|
return notAfter, nil
|
|
}
|
|
|
|
// StoreCertificate given a certificate bundle that was signed, persist the certificate to storage
|
|
func StoreCertificate(ctx context.Context, s logical.Storage, certCounter pki_backend.CertificateCounter, certBundle *certutil.ParsedCertBundle) error {
|
|
hyphenSerialNumber := parsing.NormalizeSerialForStorageFromBigInt(certBundle.Certificate.SerialNumber)
|
|
key := "certs/" + hyphenSerialNumber
|
|
certsCounted := certCounter.IsInitialized()
|
|
err := s.Put(ctx, &logical.StorageEntry{
|
|
Key: key,
|
|
Value: certBundle.CertificateBytes,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("unable to store certificate locally: %w", err)
|
|
}
|
|
certCounter.IncrementTotalCertificatesCount(certsCounted, key)
|
|
return nil
|
|
}
|