From f86fdf530fa6007e9161218194fc61c383aab7fb Mon Sep 17 00:00:00 2001 From: Alexander Scheel Date: Mon, 5 Dec 2022 10:38:26 -0500 Subject: [PATCH] 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 * Add documentation about templated AIAs Signed-off-by: Alexander Scheel * Add changelog entry Signed-off-by: Alexander Scheel * Add tests Signed-off-by: Alexander Scheel * AIA URIs -> AIA URLs Signed-off-by: Alexander Scheel * issuer.AIAURIs might be nil Signed-off-by: Alexander Scheel * Allow non-nil response to config/urls Signed-off-by: Alexander Scheel * Always validate URLs on config update Signed-off-by: Alexander Scheel * Ensure URLs lack templating parameters Signed-off-by: Alexander Scheel * Review feedback Signed-off-by: Alexander Scheel Signed-off-by: Alexander Scheel --- builtin/logical/pki/backend.go | 2 + builtin/logical/pki/backend_test.go | 104 +++++++++++++++++ builtin/logical/pki/cert_util.go | 12 +- builtin/logical/pki/path_config_cluster.go | 97 ++++++++++++++++ builtin/logical/pki/path_config_urls.go | 75 ++++++++++-- builtin/logical/pki/path_fetch_issuers.go | 46 +++++++- builtin/logical/pki/storage.go | 109 +++++++++++++++++- .../logical/pki/storage_migrations_test.go | 3 +- changelog/18199.txt | 3 + website/content/api-docs/secret/pki.mdx | 106 ++++++++++++++++- 10 files changed, 527 insertions(+), 30 deletions(-) create mode 100644 builtin/logical/pki/path_config_cluster.go create mode 100644 changelog/18199.txt diff --git a/builtin/logical/pki/backend.go b/builtin/logical/pki/backend.go index 5dbb94c77..07494dc71 100644 --- a/builtin/logical/pki/backend.go +++ b/builtin/logical/pki/backend.go @@ -100,6 +100,7 @@ func Backend(conf *logical.BackendConfig) *backend { revokedPath, deltaWALPath, legacyCRLPath, + clusterConfigPath, "crls/", "certs/", }, @@ -127,6 +128,7 @@ func Backend(conf *logical.BackendConfig) *backend { pathConfigCA(&b), pathConfigCRL(&b), pathConfigURLs(&b), + pathConfigCluster(&b), pathSignVerbatim(&b), pathSign(&b), pathIssue(&b), diff --git a/builtin/logical/pki/backend_test.go b/builtin/logical/pki/backend_test.go index 386126a86..c6a79dbfa 100644 --- a/builtin/logical/pki/backend_test.go +++ b/builtin/logical/pki/backend_test.go @@ -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) } +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 ( initTest sync.Once rsaCAKey string diff --git a/builtin/logical/pki/cert_util.go b/builtin/logical/pki/cert_util.go index 560831063..1c763d29c 100644 --- a/builtin/logical/pki/cert_util.go +++ b/builtin/logical/pki/cert_util.go @@ -144,7 +144,7 @@ func (sc *storageContext) fetchCAInfoByIssuerId(issuerId issuerID, usage issuerU entries, err := entry.GetAIAURLs(sc) 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 @@ -699,9 +699,15 @@ func generateCert(sc *storageContext, // issuer entry yet, we default to the global URLs. entries, err := getGlobalAIAURLs(ctx, sc.Storage) 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 { data.Params.MaxPathLength = -1 diff --git a/builtin/logical/pki/path_config_cluster.go b/builtin/logical/pki/path_config_cluster.go new file mode 100644 index 000000000..440dcc874 --- /dev/null +++ b/builtin/logical/pki/path_config_cluster.go @@ -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. +` diff --git a/builtin/logical/pki/path_config_urls.go b/builtin/logical/pki/path_config_urls.go index 830ca34ad..5b67ad080 100644 --- a/builtin/logical/pki/path_config_urls.go +++ b/builtin/logical/pki/path_config_urls.go @@ -3,10 +3,10 @@ package pki import ( "context" "fmt" + "strings" "github.com/asaskevich/govalidator" "github.com/hashicorp/vault/sdk/framework" - "github.com/hashicorp/vault/sdk/helper/certutil" "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 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{ @@ -49,7 +59,7 @@ for the OCSP servers attribute. See also RFC 5280 Section 4.2.2.1.`, func validateURLs(urls []string) string { for _, curr := range urls { - if !govalidator.IsURL(curr) { + if !govalidator.IsURL(curr) || strings.Contains(curr, "{{issuer_id}}") || strings.Contains(curr, "{{cluster_path}}") { return curr } } @@ -57,16 +67,17 @@ func validateURLs(urls []string) string { 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") if err != nil { return nil, err } - entries := &certutil.URLEntries{ + entries := &aiaConfigEntry{ IssuingCertificates: []string{}, CRLDistributionPoints: []string{}, OCSPServers: []string{}, + EnableTemplating: false, } if entry == nil { @@ -80,7 +91,7 @@ func getGlobalAIAURLs(ctx context.Context, storage logical.Storage) (*certutil.U 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) if err != nil { return err @@ -108,6 +119,7 @@ func (b *backend) pathReadURL(ctx context.Context, req *logical.Request, _ *fram "issuing_certificates": entries.IssuingCertificates, "crl_distribution_points": entries.CRLDistributionPoints, "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 } + if enableTemplating, ok := data.GetOk("enable_templating"); ok { + entries.EnableTemplating = enableTemplating.(bool) + } if urlsInt, ok := data.GetOk("issuing_certificates"); ok { 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 != "" { return logical.ErrorResponse(fmt.Sprintf( "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 != "" { return logical.ErrorResponse(fmt.Sprintf( "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 != "" { return logical.ErrorResponse(fmt.Sprintf( "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 = ` diff --git a/builtin/logical/pki/path_fetch_issuers.go b/builtin/logical/pki/path_fetch_issuers.go index ff7645ee7..5cc126213 100644 --- a/builtin/logical/pki/path_fetch_issuers.go +++ b/builtin/logical/pki/path_fetch_issuers.go @@ -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 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{ // Returns a JSON entry. @@ -336,16 +345,17 @@ func (b *backend) pathUpdateIssuer(ctx context.Context, req *logical.Request, da } // AIA access changes + enableTemplating := data.Get("enable_aia_url_templating").(bool) 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 } 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 } 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 } @@ -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) { - issuer.AIAURIs = &certutil.URLEntries{} + issuer.AIAURIs = &aiaConfigEntry{} } if issuer.AIAURIs != nil { // 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 } } + 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 // to ease usage later. @@ -485,6 +499,12 @@ func (b *backend) pathUpdateIssuer(ctx context.Context, req *logical.Request, da if newName != oldName { 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 } @@ -629,7 +649,7 @@ func (b *backend) pathPatchIssuer(ctx context.Context, req *logical.Request, dat // AIA access changes. if issuer.AIAURIs == nil { - issuer.AIAURIs = &certutil.URLEntries{} + issuer.AIAURIs = &aiaConfigEntry{} } // 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 _, pair := range pairs { rawURLsValue, ok := data.GetOk(pair.Source) if ok { 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 } @@ -732,6 +760,12 @@ func (b *backend) pathPatchIssuer(ctx context.Context, req *logical.Request, dat if newName != oldName { 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 } diff --git a/builtin/logical/pki/storage.go b/builtin/logical/pki/storage.go index e55ea4121..f8861a97c 100644 --- a/builtin/logical/pki/storage.go +++ b/builtin/logical/pki/storage.go @@ -30,6 +30,7 @@ const ( deltaCRLPathSuffix = "-delta" autoTidyConfigPath = "config/auto-tidy" + clusterConfigPath = "config/cluster" // Used as a quick sanity check for a reference id lookups... uuidLength = 36 @@ -168,7 +169,7 @@ type issuerEntry struct { Revoked bool `json:"revoked"` RevocationTime int64 `json:"revocation_time"` 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"` Version uint `json:"version"` } @@ -195,6 +196,66 @@ type issuerConfigEntry struct { 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 { Context context.Context 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()) } -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. - urls = i.AIAURIs + entries := i.AIAURIs // 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. - if urls == nil || (len(urls.IssuingCertificates) == 0 && len(urls.CRLDistributionPoints) == 0 && len(urls.OCSPServers) == 0) { - urls, err = getGlobalAIAURLs(sc.Context, sc.Storage) + if entries == nil || (len(entries.IssuingCertificates) == 0 && len(entries.CRLDistributionPoints) == 0 && len(entries.OCSPServers) == 0) { + 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) { @@ -1246,3 +1316,30 @@ func (sc *storageContext) listRevokedCerts() ([]string, error) { 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) +} diff --git a/builtin/logical/pki/storage_migrations_test.go b/builtin/logical/pki/storage_migrations_test.go index ef1b59e5f..65678f418 100644 --- a/builtin/logical/pki/storage_migrations_test.go +++ b/builtin/logical/pki/storage_migrations_test.go @@ -483,8 +483,7 @@ func TestExpectedOpsWork_PreMigration(t *testing.T) { }, MountPoint: "pki/", }) - require.NoError(t, err, "error setting URL config") - require.Nil(t, resp, "got non-nil response setting URL config") + requireSuccessNonNilResponse(t, resp, err) // 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"} { diff --git a/changelog/18199.txt b/changelog/18199.txt new file mode 100644 index 000000000..706a5568c --- /dev/null +++ b/changelog/18199.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/pki: Allow templating performance replication cluster- and issuer-specific AIA URLs. +``` diff --git a/website/content/api-docs/secret/pki.mdx b/website/content/api-docs/secret/pki.mdx index dfe56b6d0..d640e7443 100644 --- a/website/content/api-docs/secret/pki.mdx +++ b/website/content/api-docs/secret/pki.mdx @@ -65,6 +65,8 @@ update your API calls accordingly. - [Set Issuers Configuration](#set-issuers-configuration) - [Read Keys Configuration](#read-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) - [Set CRL Configuration](#set-crl-configuration) - [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) 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 ```json @@ -2898,7 +2910,11 @@ parameter. 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 issuer's preferences will be used instead. Otherwise, these fields are used - as a fallback. + as a fallback.

+ 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 @@ -2925,6 +2941,23 @@ parameter. [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. +- `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 ```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": "" + }, + "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 This endpoint allows getting the duration for which the generated CRL should be