Allow templating cluster-local AIA URIs (#18199)

* Allow templating of cluster-local AIA URIs

This adds a new configuration path, /config/cluster, which retains
cluster-local configuration. By extending /config/urls and its issuer
counterpart to include an enable_templating parameter, we can allow
operators to correctly identify the particular cluster a cert was
issued on, and tie its AIA information to this (cluster, issuer) pair
dynamically.

Notably, this does not solve all usage issues around AIA URIs: the CRL
and OCSP responder remain local, meaning that some merge capability is
required prior to passing it to other systems if they use CRL files and
must validate requests with certs from any arbitrary PR cluster.

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add documentation about templated AIAs

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add changelog entry

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Add tests

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* AIA URIs -> AIA URLs

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* issuer.AIAURIs might be nil

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Allow non-nil response to config/urls

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Always validate URLs on config update

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Ensure URLs lack templating parameters

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

* Review feedback

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>

Signed-off-by: Alexander Scheel <alex.scheel@hashicorp.com>
This commit is contained in:
Alexander Scheel 2022-12-05 10:38:26 -05:00 committed by GitHub
parent 2e9f0e921b
commit f86fdf530f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 527 additions and 30 deletions

View File

@ -100,6 +100,7 @@ func Backend(conf *logical.BackendConfig) *backend {
revokedPath, revokedPath,
deltaWALPath, deltaWALPath,
legacyCRLPath, legacyCRLPath,
clusterConfigPath,
"crls/", "crls/",
"certs/", "certs/",
}, },
@ -127,6 +128,7 @@ func Backend(conf *logical.BackendConfig) *backend {
pathConfigCA(&b), pathConfigCA(&b),
pathConfigCRL(&b), pathConfigCRL(&b),
pathConfigURLs(&b), pathConfigURLs(&b),
pathConfigCluster(&b),
pathSignVerbatim(&b), pathSignVerbatim(&b),
pathSign(&b), pathSign(&b),
pathIssue(&b), pathIssue(&b),

View File

@ -5966,6 +5966,110 @@ func TestPKI_ListRevokedCerts(t *testing.T) {
require.Equal(t, 4, len(certKeys), "Expected 4 cert entries got %d: %v", len(certKeys), certKeys) require.Equal(t, 4, len(certKeys), "Expected 4 cert entries got %d: %v", len(certKeys), certKeys)
} }
func TestPKI_TemplatedAIAs(t *testing.T) {
t.Parallel()
b, s := CreateBackendWithStorage(t)
// Setting templated AIAs should succeed.
_, err := CBWrite(b, s, "config/cluster", map[string]interface{}{
"path": "http://localhost:8200/v1/pki",
})
require.NoError(t, err)
aiaData := map[string]interface{}{
"crl_distribution_points": "{{cluster_path}}/issuer/{{issuer_id}}/crl/der",
"issuing_certificates": "{{cluster_path}}/issuer/{{issuer_id}}/der",
"ocsp_servers": "{{cluster_path}}/ocsp",
"enable_templating": true,
}
_, err = CBWrite(b, s, "config/urls", aiaData)
require.NoError(t, err)
// But root generation will fail.
rootData := map[string]interface{}{
"common_name": "Long-Lived Root X1",
"issuer_name": "long-root-x1",
"key_type": "ec",
}
_, err = CBWrite(b, s, "root/generate/internal", rootData)
require.Error(t, err)
require.Contains(t, err.Error(), "unable to parse AIA URL")
// Clearing the config and regenerating the root should succeed.
_, err = CBWrite(b, s, "config/urls", map[string]interface{}{
"crl_distribution_points": "",
"issuing_certificates": "",
"ocsp_servers": "",
"enable_templating": false,
})
require.NoError(t, err)
resp, err := CBWrite(b, s, "root/generate/internal", rootData)
requireSuccessNonNilResponse(t, resp, err)
issuerId := string(resp.Data["issuer_id"].(issuerID))
// Now write the original AIA config and sign a leaf.
_, err = CBWrite(b, s, "config/urls", aiaData)
require.NoError(t, err)
_, err = CBWrite(b, s, "roles/testing", map[string]interface{}{
"allow_any_name": "true",
"key_type": "ec",
"ttl": "50m",
})
require.NoError(t, err)
resp, err = CBWrite(b, s, "issue/testing", map[string]interface{}{
"common_name": "example.com",
})
requireSuccessNonNilResponse(t, resp, err)
// Validate the AIA info is correctly templated.
cert := parseCert(t, resp.Data["certificate"].(string))
require.Equal(t, cert.OCSPServer, []string{"http://localhost:8200/v1/pki/ocsp"})
require.Equal(t, cert.IssuingCertificateURL, []string{"http://localhost:8200/v1/pki/issuer/" + issuerId + "/der"})
require.Equal(t, cert.CRLDistributionPoints, []string{"http://localhost:8200/v1/pki/issuer/" + issuerId + "/crl/der"})
// Modify our issuer to set custom AIAs: these URLs are bad.
_, err = CBPatch(b, s, "issuer/default", map[string]interface{}{
"enable_aia_url_templating": "false",
"crl_distribution_points": "a",
"issuing_certificates": "b",
"ocsp_servers": "c",
})
require.Error(t, err)
// These URLs are good.
_, err = CBPatch(b, s, "issuer/default", map[string]interface{}{
"enable_aia_url_templating": "false",
"crl_distribution_points": "http://localhost/a",
"issuing_certificates": "http://localhost/b",
"ocsp_servers": "http://localhost/c",
})
resp, err = CBWrite(b, s, "issue/testing", map[string]interface{}{
"common_name": "example.com",
})
requireSuccessNonNilResponse(t, resp, err)
// Validate the AIA info is correctly templated.
cert = parseCert(t, resp.Data["certificate"].(string))
require.Equal(t, cert.OCSPServer, []string{"http://localhost/c"})
require.Equal(t, cert.IssuingCertificateURL, []string{"http://localhost/b"})
require.Equal(t, cert.CRLDistributionPoints, []string{"http://localhost/a"})
// These URLs are bad, but will fail at issuance time due to AIA templating.
resp, err = CBPatch(b, s, "issuer/default", map[string]interface{}{
"enable_aia_url_templating": "true",
"crl_distribution_points": "a",
"issuing_certificates": "b",
"ocsp_servers": "c",
})
requireSuccessNonNilResponse(t, resp, err)
require.NotEmpty(t, resp.Warnings)
_, err = CBWrite(b, s, "issue/testing", map[string]interface{}{
"common_name": "example.com",
})
require.Error(t, err)
}
var ( var (
initTest sync.Once initTest sync.Once
rsaCAKey string rsaCAKey string

View File

@ -144,7 +144,7 @@ func (sc *storageContext) fetchCAInfoByIssuerId(issuerId issuerID, usage issuerU
entries, err := entry.GetAIAURLs(sc) entries, err := entry.GetAIAURLs(sc)
if err != nil { if err != nil {
return nil, errutil.InternalError{Err: fmt.Sprintf("unable to fetch URL information: %v", err)} return nil, errutil.InternalError{Err: fmt.Sprintf("unable to fetch AIA URL information: %v", err)}
} }
caInfo.URLs = entries caInfo.URLs = entries
@ -699,9 +699,15 @@ func generateCert(sc *storageContext,
// issuer entry yet, we default to the global URLs. // issuer entry yet, we default to the global URLs.
entries, err := getGlobalAIAURLs(ctx, sc.Storage) entries, err := getGlobalAIAURLs(ctx, sc.Storage)
if err != nil { if err != nil {
return nil, nil, errutil.InternalError{Err: fmt.Sprintf("unable to fetch URL information: %v", err)} return nil, nil, errutil.InternalError{Err: fmt.Sprintf("unable to fetch AIA URL information: %v", err)}
} }
data.Params.URLs = entries
uris, err := entries.toURLEntries(sc, issuerID(""))
if err != nil {
return nil, nil, errutil.InternalError{Err: fmt.Sprintf("unable to parse AIA URL information: %v\nUsing templated AIA URL's {{issuer_id}} field when generating root certificates is not supported.", err)}
}
data.Params.URLs = uris
if input.role.MaxPathLength == nil { if input.role.MaxPathLength == nil {
data.Params.MaxPathLength = -1 data.Params.MaxPathLength = -1

View File

@ -0,0 +1,97 @@
package pki
import (
"context"
"fmt"
"github.com/asaskevich/govalidator"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
func pathConfigCluster(b *backend) *framework.Path {
return &framework.Path{
Pattern: "config/cluster",
Fields: map[string]*framework.FieldSchema{
"path": {
Type: framework.TypeString,
Description: `Canonical URI to this mount on this performance
replication cluster's external address. This is for resolving AIA URLs and
providing the {{cluster_path}} template parameter but might be used for other
purposes in the future.
This should only point back to this particular PR replica and should not ever
point to another PR cluster. It may point to any node in the PR replica,
including standby nodes, and need not always point to the active node.
For example: https://pr1.vault.example.com:8200/v1/pki`,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
Callback: b.pathWriteCluster,
},
logical.ReadOperation: &framework.PathOperation{
Callback: b.pathReadCluster,
},
},
HelpSynopsis: pathConfigClusterHelpSyn,
HelpDescription: pathConfigClusterHelpDesc,
}
}
func (b *backend) pathReadCluster(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
sc := b.makeStorageContext(ctx, req.Storage)
cfg, err := sc.getClusterConfig()
if err != nil {
return nil, err
}
resp := &logical.Response{
Data: map[string]interface{}{
"path": cfg.Path,
},
}
return resp, nil
}
func (b *backend) pathWriteCluster(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
sc := b.makeStorageContext(ctx, req.Storage)
cfg, err := sc.getClusterConfig()
if err != nil {
return nil, err
}
cfg.Path = data.Get("path").(string)
if !govalidator.IsURL(cfg.Path) {
return nil, fmt.Errorf("invalid, non-URL path given to cluster: %v", cfg.Path)
}
if err := sc.writeClusterConfig(cfg); err != nil {
return nil, err
}
resp := &logical.Response{
Data: map[string]interface{}{
"path": cfg.Path,
},
}
return resp, nil
}
const pathConfigClusterHelpSyn = `
Set cluster-local configuration, including address to this PR cluster.
`
const pathConfigClusterHelpDesc = `
This path allows you to set cluster-local configuration, including the
URI to this performance replication cluster. This allows you to use
templated AIA URLs with /config/urls and /issuer/:issuer_ref, setting the
reference to the cluster's URI.
Only one address can be specified for any given cluster.
`

View File

@ -3,10 +3,10 @@ package pki
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/certutil"
"github.com/hashicorp/vault/sdk/logical" "github.com/hashicorp/vault/sdk/logical"
) )
@ -31,6 +31,16 @@ for the CRL distribution points attribute. See also RFC 5280 Section 4.2.1.13.`,
Description: `Comma-separated list of URLs to be used Description: `Comma-separated list of URLs to be used
for the OCSP servers attribute. See also RFC 5280 Section 4.2.2.1.`, for the OCSP servers attribute. See also RFC 5280 Section 4.2.2.1.`,
}, },
"enable_templating": {
Type: framework.TypeBool,
Description: `Whether or not to enabling templating of the
above AIA fields. When templating is enabled the special values '{{issuer_id}}'
and '{{cluster_path}}' are available, but the addresses are not checked for
URI validity until issuance time. This requires /config/cluster's path to be
set on all PR Secondary clusters.`,
Default: false,
},
}, },
Operations: map[logical.Operation]framework.OperationHandler{ Operations: map[logical.Operation]framework.OperationHandler{
@ -49,7 +59,7 @@ for the OCSP servers attribute. See also RFC 5280 Section 4.2.2.1.`,
func validateURLs(urls []string) string { func validateURLs(urls []string) string {
for _, curr := range urls { for _, curr := range urls {
if !govalidator.IsURL(curr) { if !govalidator.IsURL(curr) || strings.Contains(curr, "{{issuer_id}}") || strings.Contains(curr, "{{cluster_path}}") {
return curr return curr
} }
} }
@ -57,16 +67,17 @@ func validateURLs(urls []string) string {
return "" return ""
} }
func getGlobalAIAURLs(ctx context.Context, storage logical.Storage) (*certutil.URLEntries, error) { func getGlobalAIAURLs(ctx context.Context, storage logical.Storage) (*aiaConfigEntry, error) {
entry, err := storage.Get(ctx, "urls") entry, err := storage.Get(ctx, "urls")
if err != nil { if err != nil {
return nil, err return nil, err
} }
entries := &certutil.URLEntries{ entries := &aiaConfigEntry{
IssuingCertificates: []string{}, IssuingCertificates: []string{},
CRLDistributionPoints: []string{}, CRLDistributionPoints: []string{},
OCSPServers: []string{}, OCSPServers: []string{},
EnableTemplating: false,
} }
if entry == nil { if entry == nil {
@ -80,7 +91,7 @@ func getGlobalAIAURLs(ctx context.Context, storage logical.Storage) (*certutil.U
return entries, nil return entries, nil
} }
func writeURLs(ctx context.Context, storage logical.Storage, entries *certutil.URLEntries) error { func writeURLs(ctx context.Context, storage logical.Storage, entries *aiaConfigEntry) error {
entry, err := logical.StorageEntryJSON("urls", entries) entry, err := logical.StorageEntryJSON("urls", entries)
if err != nil { if err != nil {
return err return err
@ -108,6 +119,7 @@ func (b *backend) pathReadURL(ctx context.Context, req *logical.Request, _ *fram
"issuing_certificates": entries.IssuingCertificates, "issuing_certificates": entries.IssuingCertificates,
"crl_distribution_points": entries.CRLDistributionPoints, "crl_distribution_points": entries.CRLDistributionPoints,
"ocsp_servers": entries.OCSPServers, "ocsp_servers": entries.OCSPServers,
"enable_templating": entries.EnableTemplating,
}, },
} }
@ -120,29 +132,68 @@ func (b *backend) pathWriteURL(ctx context.Context, req *logical.Request, data *
return nil, err return nil, err
} }
if enableTemplating, ok := data.GetOk("enable_templating"); ok {
entries.EnableTemplating = enableTemplating.(bool)
}
if urlsInt, ok := data.GetOk("issuing_certificates"); ok { if urlsInt, ok := data.GetOk("issuing_certificates"); ok {
entries.IssuingCertificates = urlsInt.([]string) entries.IssuingCertificates = urlsInt.([]string)
}
if urlsInt, ok := data.GetOk("crl_distribution_points"); ok {
entries.CRLDistributionPoints = urlsInt.([]string)
}
if urlsInt, ok := data.GetOk("ocsp_servers"); ok {
entries.OCSPServers = urlsInt.([]string)
}
resp := &logical.Response{
Data: map[string]interface{}{
"issuing_certificates": entries.IssuingCertificates,
"crl_distribution_points": entries.CRLDistributionPoints,
"ocsp_servers": entries.OCSPServers,
"enable_templating": entries.EnableTemplating,
},
}
if entries.EnableTemplating && !b.useLegacyBundleCaStorage() {
sc := b.makeStorageContext(ctx, req.Storage)
issuers, err := sc.listIssuers()
if err != nil {
return nil, fmt.Errorf("unable to read issuers list to validate templated URIs: %w", err)
}
if len(issuers) > 0 {
issuer, err := sc.fetchIssuerById(issuers[0])
if err != nil {
return nil, fmt.Errorf("unable to read issuer to validate templated URIs: %w", err)
}
_, err = entries.toURLEntries(sc, issuer.ID)
if err != nil {
resp.AddWarning(fmt.Sprintf("issuance may fail: %v\n\nConsider setting the cluster-local address if it is not already set.", err))
}
}
} else if !entries.EnableTemplating {
if badURL := validateURLs(entries.IssuingCertificates); badURL != "" { if badURL := validateURLs(entries.IssuingCertificates); badURL != "" {
return logical.ErrorResponse(fmt.Sprintf( return logical.ErrorResponse(fmt.Sprintf(
"invalid URL found in Authority Information Access (AIA) parameter issuing_certificates: %s", badURL)), nil "invalid URL found in Authority Information Access (AIA) parameter issuing_certificates: %s", badURL)), nil
} }
}
if urlsInt, ok := data.GetOk("crl_distribution_points"); ok {
entries.CRLDistributionPoints = urlsInt.([]string)
if badURL := validateURLs(entries.CRLDistributionPoints); badURL != "" { if badURL := validateURLs(entries.CRLDistributionPoints); badURL != "" {
return logical.ErrorResponse(fmt.Sprintf( return logical.ErrorResponse(fmt.Sprintf(
"invalid URL found in Authority Information Access (AIA) parameter crl_distribution_points: %s", badURL)), nil "invalid URL found in Authority Information Access (AIA) parameter crl_distribution_points: %s", badURL)), nil
} }
}
if urlsInt, ok := data.GetOk("ocsp_servers"); ok {
entries.OCSPServers = urlsInt.([]string)
if badURL := validateURLs(entries.OCSPServers); badURL != "" { if badURL := validateURLs(entries.OCSPServers); badURL != "" {
return logical.ErrorResponse(fmt.Sprintf( return logical.ErrorResponse(fmt.Sprintf(
"invalid URL found in Authority Information Access (AIA) parameter ocsp_servers: %s", badURL)), nil "invalid URL found in Authority Information Access (AIA) parameter ocsp_servers: %s", badURL)), nil
} }
} }
return nil, writeURLs(ctx, req.Storage, entries) if err := writeURLs(ctx, req.Storage, entries); err != nil {
return nil, err
}
return resp, nil
} }
const pathConfigURLsHelpSyn = ` const pathConfigURLsHelpSyn = `

View File

@ -134,6 +134,15 @@ for the CRL distribution points attribute. See also RFC 5280 Section 4.2.1.13.`,
Description: `Comma-separated list of URLs to be used Description: `Comma-separated list of URLs to be used
for the OCSP servers attribute. See also RFC 5280 Section 4.2.2.1.`, for the OCSP servers attribute. See also RFC 5280 Section 4.2.2.1.`,
} }
fields["enable_aia_url_templating"] = &framework.FieldSchema{
Type: framework.TypeBool,
Description: `Whether or not to enabling templating of the
above AIA fields. When templating is enabled the special values '{{issuer_id}}'
and '{{cluster_path}}' are available, but the addresses are not checked for
URL validity until issuance time. This requires /config/cluster's path to be
set on all PR Secondary clusters.`,
Default: false,
}
return &framework.Path{ return &framework.Path{
// Returns a JSON entry. // Returns a JSON entry.
@ -336,16 +345,17 @@ func (b *backend) pathUpdateIssuer(ctx context.Context, req *logical.Request, da
} }
// AIA access changes // AIA access changes
enableTemplating := data.Get("enable_aia_url_templating").(bool)
issuerCertificates := data.Get("issuing_certificates").([]string) issuerCertificates := data.Get("issuing_certificates").([]string)
if badURL := validateURLs(issuerCertificates); badURL != "" { if badURL := validateURLs(issuerCertificates); !enableTemplating && badURL != "" {
return logical.ErrorResponse(fmt.Sprintf("invalid URL found in Authority Information Access (AIA) parameter issuing_certificates: %s", badURL)), nil return logical.ErrorResponse(fmt.Sprintf("invalid URL found in Authority Information Access (AIA) parameter issuing_certificates: %s", badURL)), nil
} }
crlDistributionPoints := data.Get("crl_distribution_points").([]string) crlDistributionPoints := data.Get("crl_distribution_points").([]string)
if badURL := validateURLs(crlDistributionPoints); badURL != "" { if badURL := validateURLs(crlDistributionPoints); !enableTemplating && badURL != "" {
return logical.ErrorResponse(fmt.Sprintf("invalid URL found in Authority Information Access (AIA) parameter crl_distribution_points: %s", badURL)), nil return logical.ErrorResponse(fmt.Sprintf("invalid URL found in Authority Information Access (AIA) parameter crl_distribution_points: %s", badURL)), nil
} }
ocspServers := data.Get("ocsp_servers").([]string) ocspServers := data.Get("ocsp_servers").([]string)
if badURL := validateURLs(ocspServers); badURL != "" { if badURL := validateURLs(ocspServers); !enableTemplating && badURL != "" {
return logical.ErrorResponse(fmt.Sprintf("invalid URL found in Authority Information Access (AIA) parameter ocsp_servers: %s", badURL)), nil return logical.ErrorResponse(fmt.Sprintf("invalid URL found in Authority Information Access (AIA) parameter ocsp_servers: %s", badURL)), nil
} }
@ -393,7 +403,7 @@ func (b *backend) pathUpdateIssuer(ctx context.Context, req *logical.Request, da
} }
if issuer.AIAURIs == nil && (len(issuerCertificates) > 0 || len(crlDistributionPoints) > 0 || len(ocspServers) > 0) { if issuer.AIAURIs == nil && (len(issuerCertificates) > 0 || len(crlDistributionPoints) > 0 || len(ocspServers) > 0) {
issuer.AIAURIs = &certutil.URLEntries{} issuer.AIAURIs = &aiaConfigEntry{}
} }
if issuer.AIAURIs != nil { if issuer.AIAURIs != nil {
// Associative mapping from data source to destination on the // Associative mapping from data source to destination on the
@ -424,6 +434,10 @@ func (b *backend) pathUpdateIssuer(ctx context.Context, req *logical.Request, da
modified = true modified = true
} }
} }
if enableTemplating != issuer.AIAURIs.EnableTemplating {
issuer.AIAURIs.EnableTemplating = enableTemplating
modified = true
}
// If no AIA URLs exist on the issuer, set the AIA URLs entry to nil // If no AIA URLs exist on the issuer, set the AIA URLs entry to nil
// to ease usage later. // to ease usage later.
@ -485,6 +499,12 @@ func (b *backend) pathUpdateIssuer(ctx context.Context, req *logical.Request, da
if newName != oldName { if newName != oldName {
addWarningOnDereferencing(sc, oldName, response) addWarningOnDereferencing(sc, oldName, response)
} }
if issuer.AIAURIs != nil && issuer.AIAURIs.EnableTemplating {
_, aiaErr := issuer.AIAURIs.toURLEntries(sc, issuer.ID)
if aiaErr != nil {
response.AddWarning(fmt.Sprintf("issuance may fail: %v\n\nConsider setting the cluster-local address if it is not already set.", aiaErr))
}
}
return response, err return response, err
} }
@ -629,7 +649,7 @@ func (b *backend) pathPatchIssuer(ctx context.Context, req *logical.Request, dat
// AIA access changes. // AIA access changes.
if issuer.AIAURIs == nil { if issuer.AIAURIs == nil {
issuer.AIAURIs = &certutil.URLEntries{} issuer.AIAURIs = &aiaConfigEntry{}
} }
// Associative mapping from data source to destination on the // Associative mapping from data source to destination on the
@ -655,12 +675,20 @@ func (b *backend) pathPatchIssuer(ctx context.Context, req *logical.Request, dat
}, },
} }
if enableTemplatingRaw, ok := data.GetOk("enable_aia_url_templating"); ok {
enableTemplating := enableTemplatingRaw.(bool)
if enableTemplating != issuer.AIAURIs.EnableTemplating {
issuer.AIAURIs.EnableTemplating = true
modified = true
}
}
// For each pair, if it is different on the object, update it. // For each pair, if it is different on the object, update it.
for _, pair := range pairs { for _, pair := range pairs {
rawURLsValue, ok := data.GetOk(pair.Source) rawURLsValue, ok := data.GetOk(pair.Source)
if ok { if ok {
urlsValue := rawURLsValue.([]string) urlsValue := rawURLsValue.([]string)
if badURL := validateURLs(urlsValue); badURL != "" { if badURL := validateURLs(urlsValue); !issuer.AIAURIs.EnableTemplating && badURL != "" {
return logical.ErrorResponse(fmt.Sprintf("invalid URL found in Authority Information Access (AIA) parameter %v: %s", pair.Source, badURL)), nil return logical.ErrorResponse(fmt.Sprintf("invalid URL found in Authority Information Access (AIA) parameter %v: %s", pair.Source, badURL)), nil
} }
@ -732,6 +760,12 @@ func (b *backend) pathPatchIssuer(ctx context.Context, req *logical.Request, dat
if newName != oldName { if newName != oldName {
addWarningOnDereferencing(sc, oldName, response) addWarningOnDereferencing(sc, oldName, response)
} }
if issuer.AIAURIs != nil && issuer.AIAURIs.EnableTemplating {
_, aiaErr := issuer.AIAURIs.toURLEntries(sc, issuer.ID)
if aiaErr != nil {
response.AddWarning(fmt.Sprintf("issuance may fail: %v\n\nConsider setting the cluster-local address if it is not already set.", aiaErr))
}
}
return response, err return response, err
} }

View File

@ -30,6 +30,7 @@ const (
deltaCRLPathSuffix = "-delta" deltaCRLPathSuffix = "-delta"
autoTidyConfigPath = "config/auto-tidy" autoTidyConfigPath = "config/auto-tidy"
clusterConfigPath = "config/cluster"
// Used as a quick sanity check for a reference id lookups... // Used as a quick sanity check for a reference id lookups...
uuidLength = 36 uuidLength = 36
@ -168,7 +169,7 @@ type issuerEntry struct {
Revoked bool `json:"revoked"` Revoked bool `json:"revoked"`
RevocationTime int64 `json:"revocation_time"` RevocationTime int64 `json:"revocation_time"`
RevocationTimeUTC time.Time `json:"revocation_time_utc"` RevocationTimeUTC time.Time `json:"revocation_time_utc"`
AIAURIs *certutil.URLEntries `json:"aia_uris,omitempty"` AIAURIs *aiaConfigEntry `json:"aia_uris,omitempty"`
LastModified time.Time `json:"last_modified"` LastModified time.Time `json:"last_modified"`
Version uint `json:"version"` Version uint `json:"version"`
} }
@ -195,6 +196,66 @@ type issuerConfigEntry struct {
DefaultFollowsLatestIssuer bool `json:"default_follows_latest_issuer"` DefaultFollowsLatestIssuer bool `json:"default_follows_latest_issuer"`
} }
type clusterConfigEntry struct {
Path string `json:"path"`
}
type aiaConfigEntry struct {
IssuingCertificates []string `json:"issuing_certificates"`
CRLDistributionPoints []string `json:"crl_distribution_points"`
OCSPServers []string `json:"ocsp_servers"`
EnableTemplating bool `json:"enable_templating"`
}
func (c *aiaConfigEntry) toURLEntries(sc *storageContext, issuer issuerID) (*certutil.URLEntries, error) {
if len(c.IssuingCertificates) == 0 && len(c.CRLDistributionPoints) == 0 && len(c.OCSPServers) == 0 {
return &certutil.URLEntries{}, nil
}
result := certutil.URLEntries{
IssuingCertificates: c.IssuingCertificates[:],
CRLDistributionPoints: c.CRLDistributionPoints[:],
OCSPServers: c.OCSPServers[:],
}
if c.EnableTemplating {
cfg, err := sc.getClusterConfig()
if err != nil {
return nil, fmt.Errorf("error fetching cluster-local address config: %w", err)
}
for name, source := range map[string]*[]string{
"issuing_certificates": &result.IssuingCertificates,
"crl_distribution_points": &result.CRLDistributionPoints,
"ocsp_servers": &result.OCSPServers,
} {
templated := make([]string, len(*source))
for index, uri := range *source {
if strings.Contains(uri, "{{cluster_path}}") && len(cfg.Path) == 0 {
return nil, fmt.Errorf("unable to template AIA URLs as we lack local cluster address information")
}
if strings.Contains(uri, "{{issuer_id}}") && len(issuer) == 0 {
// Elide issuer AIA info as we lack an issuer_id.
return nil, fmt.Errorf("unable to template AIA URLs as we lack an issuer_id for this operation")
}
uri = strings.ReplaceAll(uri, "{{cluster_path}}", cfg.Path)
uri = strings.ReplaceAll(uri, "{{issuer_id}}", issuer.String())
templated[index] = uri
}
if uri := validateURLs(templated); uri != "" {
return nil, fmt.Errorf("error validating templated %v; invalid URI: %v", name, uri)
}
*source = templated
}
}
return &result, nil
}
type storageContext struct { type storageContext struct {
Context context.Context Context context.Context
Storage logical.Storage Storage logical.Storage
@ -506,17 +567,26 @@ func (i issuerEntry) CanMaybeSignWithAlgo(algo x509.SignatureAlgorithm) error {
return fmt.Errorf("unable to use issuer of type %v to sign with %v key type", cert.PublicKeyAlgorithm.String(), algo.String()) return fmt.Errorf("unable to use issuer of type %v to sign with %v key type", cert.PublicKeyAlgorithm.String(), algo.String())
} }
func (i issuerEntry) GetAIAURLs(sc *storageContext) (urls *certutil.URLEntries, err error) { func (i issuerEntry) GetAIAURLs(sc *storageContext) (*certutil.URLEntries, error) {
// Default to the per-issuer AIA URLs. // Default to the per-issuer AIA URLs.
urls = i.AIAURIs entries := i.AIAURIs
// If none are set (either due to a nil entry or because no URLs have // If none are set (either due to a nil entry or because no URLs have
// been provided), fall back to the global AIA URL config. // been provided), fall back to the global AIA URL config.
if urls == nil || (len(urls.IssuingCertificates) == 0 && len(urls.CRLDistributionPoints) == 0 && len(urls.OCSPServers) == 0) { if entries == nil || (len(entries.IssuingCertificates) == 0 && len(entries.CRLDistributionPoints) == 0 && len(entries.OCSPServers) == 0) {
urls, err = getGlobalAIAURLs(sc.Context, sc.Storage) var err error
entries, err = getGlobalAIAURLs(sc.Context, sc.Storage)
if err != nil {
return nil, err
}
} }
return urls, err if entries == nil {
return &certutil.URLEntries{}, nil
}
return entries.toURLEntries(sc, i.ID)
} }
func (sc *storageContext) listIssuers() ([]issuerID, error) { func (sc *storageContext) listIssuers() ([]issuerID, error) {
@ -1246,3 +1316,30 @@ func (sc *storageContext) listRevokedCerts() ([]string, error) {
return list, err return list, err
} }
func (sc *storageContext) getClusterConfig() (*clusterConfigEntry, error) {
entry, err := sc.Storage.Get(sc.Context, clusterConfigPath)
if err != nil {
return nil, err
}
var result clusterConfigEntry
if entry == nil {
return &result, nil
}
if err = entry.DecodeJSON(&result); err != nil {
return nil, err
}
return &result, nil
}
func (sc *storageContext) writeClusterConfig(config *clusterConfigEntry) error {
entry, err := logical.StorageEntryJSON(clusterConfigPath, config)
if err != nil {
return err
}
return sc.Storage.Put(sc.Context, entry)
}

View File

@ -483,8 +483,7 @@ func TestExpectedOpsWork_PreMigration(t *testing.T) {
}, },
MountPoint: "pki/", MountPoint: "pki/",
}) })
require.NoError(t, err, "error setting URL config") requireSuccessNonNilResponse(t, resp, err)
require.Nil(t, resp, "got non-nil response setting URL config")
// Make sure we can fetch the old values... // Make sure we can fetch the old values...
for _, path := range []string{"ca/pem", "ca_chain", "cert/" + serialNum, "cert/ca", "cert/crl", "cert/ca_chain", "config/crl", "config/urls"} { for _, path := range []string{"ca/pem", "ca_chain", "cert/" + serialNum, "cert/ca", "cert/crl", "cert/ca_chain", "config/crl", "config/urls"} {

3
changelog/18199.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
secrets/pki: Allow templating performance replication cluster- and issuer-specific AIA URLs.
```

View File

@ -65,6 +65,8 @@ update your API calls accordingly.
- [Set Issuers Configuration](#set-issuers-configuration) - [Set Issuers Configuration](#set-issuers-configuration)
- [Read Keys Configuration](#read-keys-configuration) - [Read Keys Configuration](#read-keys-configuration)
- [Set Keys Configuration](#set-keys-configuration) - [Set Keys Configuration](#set-keys-configuration)
- [Read Cluster Configuration](#read-cluster-configuration)
- [Set Cluster Configuration](#set-cluster-configuration)
- [Read CRL Configuration](#read-crl-configuration) - [Read CRL Configuration](#read-crl-configuration)
- [Set CRL Configuration](#set-crl-configuration) - [Set CRL Configuration](#set-crl-configuration)
- [Rotate CRLs](#rotate-crls) - [Rotate CRLs](#rotate-crls)
@ -2159,6 +2161,16 @@ do so, import a new issuer and a new `issuer_id` will be assigned.
[RFC 5280 Section 4.2.2.1](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.2.1) [RFC 5280 Section 4.2.2.1](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.2.1)
for information about the Authority Information Access field. for information about the Authority Information Access field.
- `enable_aia_url_templating` `(bool: false)` - Specifies that the above AIA
URL values (`issuing_certificates`, `crl_distribution_points`, and
`ocsp_servers`) should be templated. This replaces the literal value
`{{issuer_id}}` with the ID of the issuer doing the issuance, and the
literal value `{{cluster_path}}` with the value of `path` from the
cluster-local configuration endpoint `/config/cluster`.
~> **Note**: If no cluster-local address is present and templating is used,
issuance will fail.
#### Sample Payload #### Sample Payload
```json ```json
@ -2898,7 +2910,11 @@ parameter.
suggested to use the per-issuer AIA information instead of the global suggested to use the per-issuer AIA information instead of the global
AIA information. If any of the per-issuer AIA fields are set, the entire AIA information. If any of the per-issuer AIA fields are set, the entire
issuer's preferences will be used instead. Otherwise, these fields are used issuer's preferences will be used instead. Otherwise, these fields are used
as a fallback. as a fallback.<br /><br />
This can be achieved by using _templated_ global AIA values, but setting
the cluster-local address in configuration. When used, this value **must**
be set on all performance replication clusters, otherwise issuance will
fail!
#### Parameters #### Parameters
@ -2925,6 +2941,23 @@ parameter.
[RFC 5280 Section 4.2.2.1](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.2.1) [RFC 5280 Section 4.2.2.1](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.2.1)
for information about the Authority Information Access field. for information about the Authority Information Access field.
- `enable_templating` `(bool: false)` - Specifies that the above AIA
URL values (`issuing_certificates`, `crl_distribution_points`, and
`ocsp_servers`) should be templated. This replaces the literal value
`{{issuer_id}}` with the ID of the issuer doing the issuance, and the
literal value `{{cluster_path}}` with the value of `path` from the
cluster-local configuration endpoint `/config/cluster`.
For example, the following values can be used globally to ensure all AIA
URIs use the cluster-local, per-issuer canonical reference:
- `issuing_certificates={{cluster_path}}/issuer/{{issuer_id}}/der`
- `crl_distribution_points={{cluster_path}}/issuer/{{issuer_id}}/crl/der`
- `ocsp_servers={{cluster_path}}/ocsp`
~> **Note**: If no cluster-local address is present and templating is used,
issuance will fail.
#### Sample Payload #### Sample Payload
```json ```json
@ -3101,6 +3134,77 @@ $ curl \
} }
``` ```
### Read Cluster Configuration
This endpoint fetches the cluster-local configuration.
Presently the only cluster-local config option is `path`, which sets the URL
to this mount on a particular performance replication cluster. This is useful
for populating `{{cluster_path}}` for AIA URL templating, but may be used for
other uses in the future.
| Method | Path |
| :----- | :-------------------- |
| `GET` | `/pki/config/cluster` |
#### Sample Request
```shell-session
$ curl \
--header "X-Vault-Token: ..." \
http://127.0.0.1:8200/v1/pki/config/cluster
```
#### Sample Response
```json
{
"lease_id": "",
"renewable": false,
"lease_duration": 0,
"data": {
"path": "<url>"
},
"auth": null
}
```
### Set Cluster Configuration
This endpoint sets cluster-local configuration.
Presently the only cluster-local config option is `path`, which sets the URL
to this mount on a particular performance replication cluster. This is useful
for AIA URL templating.
| Method | Path |
| :----- | :-------------------- |
| `POST` | `/pki/config/cluster` |
#### Parameters
- `path` `(string: "")` - Specifies the path to this performance replication
cluster's API mount path, including any namespaces as path components.
For example, `https://pr-a.vault.example.com/v1/ns1/pki-root`.
#### Sample Payload
```json
{
"path": ["https://..."]
}
```
#### Sample Request
```shell-session
$ curl \
--header "X-Vault-Token: ..." \
--request POST \
--data @payload.json \
http://127.0.0.1:8200/v1/pki/config/cluster
```
### Read CRL Configuration ### Read CRL Configuration
This endpoint allows getting the duration for which the generated CRL should be This endpoint allows getting the duration for which the generated CRL should be