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