diff --git a/builtin/logical/pki/backend_test.go b/builtin/logical/pki/backend_test.go index 1b346d5f6..5a159236c 100644 --- a/builtin/logical/pki/backend_test.go +++ b/builtin/logical/pki/backend_test.go @@ -3724,6 +3724,159 @@ func TestBackend_RevokePlusTidy_Intermediate(t *testing.T) { } } +func TestBackend_Root_FullCAChain(t *testing.T) { + coreConfig := &vault.CoreConfig{ + LogicalBackends: map[string]logical.Factory{ + "pki": Factory, + }, + } + cluster := vault.NewTestCluster(t, coreConfig, &vault.TestClusterOptions{ + HandlerFunc: vaulthttp.Handler, + }) + cluster.Start() + defer cluster.Cleanup() + + client := cluster.Cores[0].Client + var err error + + // Generate a root CA at /pki-root + err = client.Sys().Mount("pki-root", &api.MountInput{ + Type: "pki", + Config: api.MountConfigInput{ + DefaultLeaseTTL: "16h", + MaxLeaseTTL: "32h", + }, + }) + if err != nil { + t.Fatal(err) + } + + resp, err := client.Logical().Write("pki-root/root/generate/exported", map[string]interface{}{ + "common_name": "root myvault.com", + }) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected ca info") + } + rootData := resp.Data + rootCert := rootData["certificate"].(string) + + // Validate that root's /cert/ca-chain now contains the certificate. + resp, err = client.Logical().Read("pki-root/cert/ca_chain") + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected intermediate chain information") + } + + fullChain := resp.Data["ca_chain"].(string) + if !strings.Contains(fullChain, rootCert) { + t.Fatal("expected full chain to contain root certificate") + } + + // Now generate an intermediate at /pki-intermediate, signed by the root. + err = client.Sys().Mount("pki-intermediate", &api.MountInput{ + Type: "pki", + Config: api.MountConfigInput{ + DefaultLeaseTTL: "16h", + MaxLeaseTTL: "32h", + }, + }) + if err != nil { + t.Fatal(err) + } + + resp, err = client.Logical().Write("pki-intermediate/intermediate/generate/exported", map[string]interface{}{ + "common_name": "intermediate myvault.com", + }) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected intermediate CSR info") + } + intermediateData := resp.Data + intermediateKey := intermediateData["private_key"].(string) + + resp, err = client.Logical().Write("pki-root/root/sign-intermediate", map[string]interface{}{ + "csr": intermediateData["csr"], + "format": "pem_bundle", + }) + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected signed intermediate info") + } + intermediateSignedData := resp.Data + intermediateCert := intermediateSignedData["certificate"].(string) + + resp, err = client.Logical().Write("pki-intermediate/intermediate/set-signed", map[string]interface{}{ + "certificate": intermediateCert + "\n" + rootCert + "\n", + }) + if err != nil { + t.Fatal(err) + } + + // Validate that intermediate's ca_chain field now includes the full + // chain. + resp, err = client.Logical().Read("pki-intermediate/cert/ca_chain") + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected intermediate chain information") + } + + fullChain = resp.Data["ca_chain"].(string) + if !strings.Contains(fullChain, intermediateCert) { + t.Fatal("expected full chain to contain intermediate certificate") + } + if !strings.Contains(fullChain, rootCert) { + t.Fatal("expected full chain to contain root certificate") + } + + // Finally, import this signing cert chain into a new mount to ensure + // "external" CAs behave as expected. + err = client.Sys().Mount("pki-external", &api.MountInput{ + Type: "pki", + Config: api.MountConfigInput{ + DefaultLeaseTTL: "16h", + MaxLeaseTTL: "32h", + }, + }) + if err != nil { + t.Fatal(err) + } + + resp, err = client.Logical().Write("pki-external/config/ca", map[string]interface{}{ + "pem_bundle": intermediateKey + "\n" + intermediateCert + "\n" + rootCert + "\n", + }) + if err != nil { + t.Fatal(err) + } + + // Validate the external chain information was loaded correctly. + resp, err = client.Logical().Read("pki-external/cert/ca_chain") + if err != nil { + t.Fatal(err) + } + if resp == nil { + t.Fatal("expected intermediate chain information") + } + + fullChain = resp.Data["ca_chain"].(string) + if !strings.Contains(fullChain, intermediateCert) { + t.Fatal("expected full chain to contain intermediate certificate") + } + if !strings.Contains(fullChain, rootCert) { + t.Fatal("expected full chain to contain root certificate") + } +} + var ( initTest sync.Once rsaCAKey string diff --git a/builtin/logical/pki/path_fetch.go b/builtin/logical/pki/path_fetch.go index 07a8eeccf..3636062f8 100644 --- a/builtin/logical/pki/path_fetch.go +++ b/builtin/logical/pki/path_fetch.go @@ -140,6 +140,7 @@ func (b *backend) pathFetchRead(ctx context.Context, req *logical.Request, data var certEntry, revokedEntry *logical.StorageEntry var funcErr error var certificate []byte + var fullChain []byte var revocationTime int64 response = &logical.Response{ Data: map[string]interface{}{}, @@ -207,6 +208,18 @@ func (b *backend) pathFetchRead(ctx context.Context, req *logical.Request, data certStr = strings.Join([]string{certStr, strings.TrimSpace(string(pem.EncodeToMemory(&block)))}, "\n") } certificate = []byte(strings.TrimSpace(certStr)) + + rawChain := caInfo.GetFullChain() + var chainStr string + for _, ca := range rawChain { + block := pem.Block{ + Type: "CERTIFICATE", + Bytes: ca.Bytes, + } + chainStr = strings.Join([]string{certStr, strings.TrimSpace(string(pem.EncodeToMemory(&block)))}, "\n") + } + fullChain = []byte(strings.TrimSpace(chainStr)) + goto reply } @@ -288,6 +301,10 @@ reply: default: response.Data["certificate"] = string(certificate) response.Data["revocation_time"] = revocationTime + + if len(fullChain) > 0 { + response.Data["ca_chain"] = string(fullChain) + } } return diff --git a/changelog/13935.txt b/changelog/13935.txt new file mode 100644 index 000000000..4066f53e5 --- /dev/null +++ b/changelog/13935.txt @@ -0,0 +1,3 @@ +```release-note:improvement +secrets/pki: Return complete chain (in `ca_chain` field) on calls to `pki/cert/ca_chain` +``` diff --git a/sdk/helper/certutil/types.go b/sdk/helper/certutil/types.go index f384fa500..076a4e352 100644 --- a/sdk/helper/certutil/types.go +++ b/sdk/helper/certutil/types.go @@ -687,6 +687,21 @@ func (b *CAInfoBundle) GetCAChain() []*CertBlock { return chain } +func (b *CAInfoBundle) GetFullChain() []*CertBlock { + var chain []*CertBlock + + chain = append(chain, &CertBlock{ + Certificate: b.Certificate, + Bytes: b.CertificateBytes, + }) + + if len(b.CAChain) > 0 { + chain = append(chain, b.CAChain...) + } + + return chain +} + type CertExtKeyUsage int const ( diff --git a/website/content/api-docs/secret/pki.mdx b/website/content/api-docs/secret/pki.mdx index f107fc128..8b0fda311 100644 --- a/website/content/api-docs/secret/pki.mdx +++ b/website/content/api-docs/secret/pki.mdx @@ -75,6 +75,9 @@ $ curl \ This endpoint retrieves the CA certificate chain, including the CA _in PEM format_. This is a bare endpoint that does not return a standard Vault data structure and cannot be read by the Vault CLI; use `/pki/cert` for that. +Additionally, note that this doesn't include the root authority and so may +return empty data depending on configuration; use `/pki/cert/ca_chain`'s +`ca_chain` JSON data field for the entire chain including issuing authority. This is an unauthenticated endpoint. @@ -114,7 +117,8 @@ This is an unauthenticated endpoint. - `` for the certificate with the given serial number - `ca` for the CA certificate - `crl` for the current CRL - - `ca_chain` for the CA trust chain or a serial number in either hyphen-separated or colon-separated octal format + - `ca_chain` for the CA trust chain; intermediate certificates in the `certificate` + field, full chain in the `ca_chain` field. ### Sample Request