Add support to load roles and issuers within ACME wrapper (#20333)

* Add support to load roles and issuers within ACME wrapper

* Add missing go doc to new test

* PR feedback

 - Move field definitions into fields.go
 - Update wording and associated errors to some role failures.
 - Add missing ':' to error messages
This commit is contained in:
Steven Clark 2023-04-25 09:29:07 -04:00 committed by GitHub
parent 7d631cb44f
commit 47605c0d48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 371 additions and 153 deletions

View File

@ -19,6 +19,8 @@ type acmeContext struct {
// baseUrl is the combination of the configured cluster local URL and the acmePath up to /acme/
baseUrl *url.URL
sc *storageContext
role *roleEntry
issuer *issuerEntry
}
type (
@ -47,20 +49,99 @@ func (b *backend) acmeWrapper(op acmeOperation) framework.OperationFunc {
return nil, fmt.Errorf("ACME is disabled in configuration: %w", ErrServerInternal)
}
if b.useLegacyBundleCaStorage() {
return nil, fmt.Errorf("%w: Can not perform ACME operations until migration has completed", ErrServerInternal)
}
acmeBaseUrl, err := getAcmeBaseUrl(sc, r.Path)
if err != nil {
return nil, err
}
role, issuer, err := getAcmeRoleAndIssuer(sc, data)
if err != nil {
return nil, err
}
acmeCtx := &acmeContext{
baseUrl: acmeBaseUrl,
sc: sc,
role: role,
issuer: issuer,
}
return op(acmeCtx, r, data)
})
}
func getAcmeIssuer(sc *storageContext, issuerName string) (*issuerEntry, error) {
issuerId, err := sc.resolveIssuerReference(issuerName)
if err != nil {
return nil, fmt.Errorf("%w: issuer does not exist", ErrMalformed)
}
issuer, err := sc.fetchIssuerById(issuerId)
if err != nil {
return nil, fmt.Errorf("issuer failed to load: %w", err)
}
if issuer.Usage.HasUsage(IssuanceUsage) && len(issuer.KeyID) > 0 {
return issuer, nil
}
return nil, fmt.Errorf("%w: issuer missing proper issuance usage or key", ErrServerInternal)
}
func getAcmeRoleAndIssuer(sc *storageContext, data *framework.FieldData) (*roleEntry, *issuerEntry, error) {
requestedIssuer := defaultRef
requestedIssuerRaw, present := data.GetOk("issuer")
if present {
requestedIssuer = requestedIssuerRaw.(string)
}
var role *roleEntry
roleNameRaw, present := data.GetOk("role")
if present {
roleName := roleNameRaw.(string)
if len(roleName) > 0 {
var err error
role, err = sc.Backend.getRole(sc.Context, sc.Storage, roleName)
if err != nil {
return nil, nil, fmt.Errorf("%w: err loading role", ErrServerInternal)
}
if role == nil {
return nil, nil, fmt.Errorf("%w: role does not exist", ErrMalformed)
}
if role.NoStore {
return nil, nil, fmt.Errorf("%w: role can not be used as NoStore is set to true", ErrServerInternal)
}
if len(role.Issuer) > 0 {
requestedIssuer = role.Issuer
}
}
} else {
role = buildSignVerbatimRoleWithNoData(&roleEntry{
Issuer: requestedIssuer,
NoStore: false,
Name: "",
})
}
issuer, err := getAcmeIssuer(sc, requestedIssuer)
if err != nil {
return nil, nil, err
}
role.Issuer = requestedIssuer
// TODO: Need additional configuration validation here, for allowed roles/issuers.
return role, issuer, nil
}
func (b *backend) acmeParsedWrapper(op acmeParsedOperation) framework.OperationFunc {
return b.acmeWrapper(func(acmeCtx *acmeContext, r *logical.Request, fields *framework.FieldData) (*logical.Response, error) {
user, data, err := b.acmeState.ParseRequestParams(acmeCtx, fields)

View File

@ -0,0 +1,114 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package pki
import (
"context"
"fmt"
"testing"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
"github.com/stretchr/testify/require"
)
// TestACMEIssuerRoleLoading validates the role and issuer loading logic within the base
// ACME wrapper is correct.
func TestACMEIssuerRoleLoading(t *testing.T) {
b, s := CreateBackendWithStorage(t)
_, err := CBWrite(b, s, "config/cluster", map[string]interface{}{
"path": "http://localhost:8200/v1/pki",
"aia_path": "http://localhost:8200/cdn/pki",
})
require.NoError(t, err)
_, err = CBWrite(b, s, "root/generate/internal", map[string]interface{}{
"common_name": "myvault1.com",
"issuer_name": "issuer-1",
"key_type": "ec",
})
require.NoError(t, err, "failed creating issuer issuer-1")
_, err = CBWrite(b, s, "root/generate/internal", map[string]interface{}{
"common_name": "myvault2.com",
"issuer_name": "issuer-2",
"key_type": "ec",
})
require.NoError(t, err, "failed creating issuer issuer-2")
_, err = CBWrite(b, s, "roles/role-bad-issuer", map[string]interface{}{
issuerRefParam: "non-existant",
"no_store": "false",
})
require.NoError(t, err, "failed creating role role-bad-issuer")
_, err = CBWrite(b, s, "roles/role-no-store-enabled", map[string]interface{}{
issuerRefParam: "issuer-2",
"no_store": "true",
})
require.NoError(t, err, "failed creating role role-no-store-enabled")
_, err = CBWrite(b, s, "roles/role-issuer-2", map[string]interface{}{
issuerRefParam: "issuer-2",
"no_store": "false",
})
require.NoError(t, err, "failed creating role role-issuer-2")
tc := []struct {
name string
roleName string
issuerName string
expectedIssuerName string
expectErr bool
}{
{name: "pass-default-use-default", roleName: "", issuerName: "", expectedIssuerName: "issuer-1", expectErr: false},
{name: "pass-role-issuer-2", roleName: "role-issuer-2", issuerName: "", expectedIssuerName: "issuer-2", expectErr: false},
{name: "pass-issuer-1-no-role", roleName: "", issuerName: "issuer-1", expectedIssuerName: "issuer-1", expectErr: false},
{name: "fail-role-has-bad-issuer", roleName: "role-bad-issuer", issuerName: "", expectedIssuerName: "", expectErr: true},
{name: "fail-role-no-store-enabled", roleName: "role-no-store-enabled", issuerName: "", expectedIssuerName: "", expectErr: true},
{name: "fail-role-no-store-enabled", roleName: "role-no-store-enabled", issuerName: "", expectedIssuerName: "", expectErr: true},
{name: "fail-role-does-not-exist", roleName: "non-existant", issuerName: "", expectedIssuerName: "", expectErr: true},
{name: "fail-issuer-does-not-exist", roleName: "", issuerName: "non-existant", expectedIssuerName: "", expectErr: true},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
f := b.acmeWrapper(func(acmeCtx *acmeContext, r *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
if tt.roleName != acmeCtx.role.Name {
return nil, fmt.Errorf("expected role %s but got %s", tt.roleName, acmeCtx.role.Name)
}
if tt.expectedIssuerName != acmeCtx.issuer.Name {
return nil, fmt.Errorf("expected issuer %s but got %s", tt.expectedIssuerName, acmeCtx.issuer.Name)
}
return nil, nil
})
fieldRaw := map[string]interface{}{}
if tt.roleName != "" {
fieldRaw["role"] = tt.roleName
}
if tt.issuerName != "" {
fieldRaw[issuerRefParam] = tt.issuerName
}
resp, err := f(context.Background(), &logical.Request{Storage: s}, &framework.FieldData{
Raw: fieldRaw,
Schema: getCsrSignVerbatimSchemaFields(),
})
require.NoError(t, err, "all errors should be re-encoded")
if tt.expectErr {
require.NotEqual(t, 200, resp.Data[logical.HTTPStatusCode])
require.Equal(t, ErrorContentType, resp.Data[logical.HTTPContentType])
} else {
if resp != nil {
t.Fatalf("expected no error got %s", string(resp.Data[logical.HTTPRawBody].([]uint8)))
}
}
})
}
}

View File

@ -264,3 +264,61 @@ func existingKeyGeneratorFromBytes(key *keyEntry) certutil.KeyGenerator {
return nil
}
}
func buildSignVerbatimRoleWithNoData(role *roleEntry) *roleEntry {
data := &framework.FieldData{
Raw: map[string]interface{}{},
Schema: addSignVerbatimRoleFields(map[string]*framework.FieldSchema{}),
}
return buildSignVerbatimRole(data, role)
}
func buildSignVerbatimRole(data *framework.FieldData, role *roleEntry) *roleEntry {
entry := &roleEntry{
AllowLocalhost: true,
AllowAnyName: true,
AllowIPSANs: true,
AllowWildcardCertificates: new(bool),
EnforceHostnames: false,
KeyType: "any",
UseCSRCommonName: true,
UseCSRSANs: true,
AllowedOtherSANs: []string{"*"},
AllowedSerialNumbers: []string{"*"},
AllowedURISANs: []string{"*"},
AllowedUserIDs: []string{"*"},
CNValidations: []string{"disabled"},
GenerateLease: new(bool),
// If adding new fields to be read, update the field list within addSignVerbatimRoleFields
KeyUsage: data.Get("key_usage").([]string),
ExtKeyUsage: data.Get("ext_key_usage").([]string),
ExtKeyUsageOIDs: data.Get("ext_key_usage_oids").([]string),
SignatureBits: data.Get("signature_bits").(int),
UsePSS: data.Get("use_pss").(bool),
}
*entry.AllowWildcardCertificates = true
*entry.GenerateLease = false
if role != nil {
if role.TTL > 0 {
entry.TTL = role.TTL
}
if role.MaxTTL > 0 {
entry.MaxTTL = role.MaxTTL
}
if role.GenerateLease != nil {
*entry.GenerateLease = *role.GenerateLease
}
if role.NotBeforeDuration > 0 {
entry.NotBeforeDuration = role.NotBeforeDuration
}
entry.NoStore = role.NoStore
entry.Issuer = role.Issuer
}
if len(entry.Issuer) == 0 {
entry.Issuer = defaultRef
}
return entry
}

View File

@ -565,3 +565,72 @@ primary node.`,
return fields
}
// generate the entire list of schema fields we need for CSR sign verbatim, this is also
// leveraged by ACME internally.
func getCsrSignVerbatimSchemaFields() map[string]*framework.FieldSchema {
fields := map[string]*framework.FieldSchema{}
fields = addNonCACommonFields(fields)
fields = addSignVerbatimRoleFields(fields)
fields["csr"] = &framework.FieldSchema{
Type: framework.TypeString,
Default: "",
Description: `PEM-format CSR to be signed. Values will be
taken verbatim from the CSR, except for
basic constraints.`,
}
return fields
}
// addSignVerbatimRoleFields provides the fields and defaults to be used by anything that is building up the fields
// and their corresponding default values when generating/using a sign-verbatim type role such as buildSignVerbatimRole.
func addSignVerbatimRoleFields(fields map[string]*framework.FieldSchema) map[string]*framework.FieldSchema {
fields["key_usage"] = &framework.FieldSchema{
Type: framework.TypeCommaStringSlice,
Default: []string{"DigitalSignature", "KeyAgreement", "KeyEncipherment"},
Description: `A comma-separated string or list of key usages (not extended
key usages). Valid values can be found at
https://golang.org/pkg/crypto/x509/#KeyUsage
-- simply drop the "KeyUsage" part of the name.
To remove all key usages from being set, set
this value to an empty list.`,
}
fields["ext_key_usage"] = &framework.FieldSchema{
Type: framework.TypeCommaStringSlice,
Default: []string{},
Description: `A comma-separated string or list of extended key usages. Valid values can be found at
https://golang.org/pkg/crypto/x509/#ExtKeyUsage
-- simply drop the "ExtKeyUsage" part of the name.
To remove all key usages from being set, set
this value to an empty list.`,
}
fields["ext_key_usage_oids"] = &framework.FieldSchema{
Type: framework.TypeCommaStringSlice,
Description: `A comma-separated string or list of extended key usage oids.`,
}
fields["signature_bits"] = &framework.FieldSchema{
Type: framework.TypeInt,
Default: 0,
Description: `The number of bits to use in the signature
algorithm; accepts 256 for SHA-2-256, 384 for SHA-2-384, and 512 for
SHA-2-512. Defaults to 0 to automatically detect based on key length
(SHA-2-256 for RSA keys, and matching the curve size for NIST P-Curves).`,
DisplayAttrs: &framework.DisplayAttributes{
Value: 0,
},
}
fields["use_pss"] = &framework.FieldSchema{
Type: framework.TypeBool,
Default: false,
Description: `Whether or not to use PSS signatures when using a
RSA key-type issuer. Defaults to false.`,
}
return fields
}

View File

@ -404,31 +404,6 @@ func storeCertificate(sc *storageContext, signedCertBundle *certutil.ParsedCertB
}
func issueCertFromCsr(ac *acmeContext, csr *x509.CertificateRequest) (*certutil.ParsedCertBundle, issuerID, error) {
entry := &roleEntry{
AllowLocalhost: true,
AllowAnyName: true,
AllowIPSANs: true,
AllowWildcardCertificates: new(bool),
EnforceHostnames: false,
KeyType: "any",
UseCSRCommonName: true,
UseCSRSANs: true,
AllowedOtherSANs: []string{"*"},
AllowedSerialNumbers: []string{"*"},
AllowedURISANs: []string{"*"},
AllowedUserIDs: []string{"*"},
CNValidations: []string{"disabled"},
GenerateLease: new(bool),
KeyUsage: []string{},
ExtKeyUsage: []string{},
ExtKeyUsageOIDs: []string{},
SignatureBits: 0,
UsePSS: false,
Issuer: defaultRef,
}
*entry.AllowWildcardCertificates = true
*entry.GenerateLease = false
pemBlock := &pem.Block{
Type: "CERTIFICATE REQUEST",
Headers: nil,
@ -436,27 +411,22 @@ func issueCertFromCsr(ac *acmeContext, csr *x509.CertificateRequest) (*certutil.
}
pemCsr := string(pem.EncodeToMemory(pemBlock))
signingBundle, issuerId, err := ac.sc.fetchCAInfoWithIssuer(entry.Issuer, IssuanceUsage)
data := &framework.FieldData{
Raw: map[string]interface{}{
"csr": pemCsr,
},
Schema: getCsrSignVerbatimSchemaFields(),
}
signingBundle, issuerId, err := ac.sc.fetchCAInfoWithIssuer(ac.issuer.ID.String(), IssuanceUsage)
if err != nil {
return nil, "", fmt.Errorf("failed loading CA %s: %w", entry.Issuer, err)
return nil, "", fmt.Errorf("failed loading CA %s: %w", ac.issuer.ID.String(), err)
}
input := &inputBundle{
req: &logical.Request{},
apiData: &framework.FieldData{
Raw: map[string]interface{}{
"csr": pemCsr,
"ttl": "1h",
},
Schema: map[string]*framework.FieldSchema{
"csr": {Type: framework.TypeString},
"serial_number": {Type: framework.TypeString},
"exclude_cn_from_sans": {Type: framework.TypeBool},
"other_sans": {Type: framework.TypeCommaStringSlice},
"ttl": {Type: framework.TypeDurationSecond},
},
},
role: entry,
req: &logical.Request{},
apiData: data,
role: ac.role,
}
if csr.PublicKeyAlgorithm == x509.UnknownPublicKeyAlgorithm || csr.PublicKey == nil {

View File

@ -384,19 +384,22 @@ func setupAcmeBackend(t *testing.T) (*vault.TestCluster, *api.Client, string) {
// Allow certain headers to pass through for ACME support
_, err = client.Logical().WriteWithContext(context.Background(), "sys/mounts/pki/tune", map[string]interface{}{
"allowed_response_headers": []string{"Last-Modified", "Replay-Nonce", "Link", "Location"},
"max_lease_ttl": "920000h",
})
require.NoError(t, err, "failed tuning mount response headers")
_, err = client.Logical().WriteWithContext(context.Background(), "/pki/issuers/generate/root/internal", map[string]interface{}{
"issuer_name": "root-ca",
"key_name": "root-key",
"key_type": "ec",
"common_name": "root.com",
"ttl": "10h",
})
resp, err := client.Logical().WriteWithContext(context.Background(), "/pki/issuers/generate/root/internal",
map[string]interface{}{
"issuer_name": "root-ca",
"key_name": "root-key",
"key_type": "ec",
"common_name": "root.com",
"ttl": "7200h",
"max_ttl": "920000h",
})
require.NoError(t, err, "failed creating root CA")
resp, err := client.Logical().WriteWithContext(context.Background(), "/pki/issuers/generate/intermediate/internal",
resp, err = client.Logical().WriteWithContext(context.Background(), "/pki/issuers/generate/intermediate/internal",
map[string]interface{}{
"key_name": "int-key",
"key_type": "ec",
@ -407,8 +410,9 @@ func setupAcmeBackend(t *testing.T) (*vault.TestCluster, *api.Client, string) {
// Sign the intermediate CSR using /pki
resp, err = client.Logical().Write("pki/issuer/root-ca/sign-intermediate", map[string]interface{}{
"csr": intermediateCSR,
"ttl": "5h",
"csr": intermediateCSR,
"ttl": "720h",
"max_ttl": "7200h",
})
require.NoError(t, err, "failed signing intermediary CSR")
intermediateCertPEM := resp.Data["certificate"].(string)
@ -425,10 +429,26 @@ func setupAcmeBackend(t *testing.T) (*vault.TestCluster, *api.Client, string) {
_, err = client.Logical().Write("/pki/issuer/"+intCaUuid, map[string]interface{}{
"issuer_name": "int-ca",
})
require.NoError(t, err, "failed updating issuer name")
_, err = client.Logical().Write("/pki/config/issuers", map[string]interface{}{
"default": "int-ca",
})
require.NoError(t, err, "failed updating default issuer")
_, err = client.Logical().Write("/pki/roles/test-role", map[string]interface{}{
"ttl_duration": "365h",
"max_ttl_duration": "720h",
"key_type": "any",
})
require.NoError(t, err, "failed creating role test-role")
_, err = client.Logical().Write("/pki/roles/acme", map[string]interface{}{
"ttl_duration": "365h",
"max_ttl_duration": "720h",
"key_type": "any",
})
require.NoError(t, err, "failed creating role acme")
return cluster, client, pathConfig
}

View File

@ -226,7 +226,7 @@ func buildPathIssuerSignVerbatim(b *backend, pattern string, displayAttrs *frame
ret := &framework.Path{
Pattern: pattern,
DisplayAttrs: displayAttrs,
Fields: map[string]*framework.FieldSchema{},
Fields: getCsrSignVerbatimSchemaFields(),
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{
@ -280,61 +280,6 @@ func buildPathIssuerSignVerbatim(b *backend, pattern string, displayAttrs *frame
HelpDescription: pathIssuerSignVerbatimHelpDesc,
}
ret.Fields = addNonCACommonFields(ret.Fields)
ret.Fields["csr"] = &framework.FieldSchema{
Type: framework.TypeString,
Default: "",
Description: `PEM-format CSR to be signed. Values will be
taken verbatim from the CSR, except for
basic constraints.`,
}
ret.Fields["key_usage"] = &framework.FieldSchema{
Type: framework.TypeCommaStringSlice,
Default: []string{"DigitalSignature", "KeyAgreement", "KeyEncipherment"},
Description: `A comma-separated string or list of key usages (not extended
key usages). Valid values can be found at
https://golang.org/pkg/crypto/x509/#KeyUsage
-- simply drop the "KeyUsage" part of the name.
To remove all key usages from being set, set
this value to an empty list.`,
}
ret.Fields["ext_key_usage"] = &framework.FieldSchema{
Type: framework.TypeCommaStringSlice,
Default: []string{},
Description: `A comma-separated string or list of extended key usages. Valid values can be found at
https://golang.org/pkg/crypto/x509/#ExtKeyUsage
-- simply drop the "ExtKeyUsage" part of the name.
To remove all key usages from being set, set
this value to an empty list.`,
}
ret.Fields["ext_key_usage_oids"] = &framework.FieldSchema{
Type: framework.TypeCommaStringSlice,
Description: `A comma-separated string or list of extended key usage oids.`,
}
ret.Fields["signature_bits"] = &framework.FieldSchema{
Type: framework.TypeInt,
Default: 0,
Description: `The number of bits to use in the signature
algorithm; accepts 256 for SHA-2-256, 384 for SHA-2-384, and 512 for
SHA-2-512. Defaults to 0 to automatically detect based on key length
(SHA-2-256 for RSA keys, and matching the curve size for NIST P-Curves).`,
DisplayAttrs: &framework.DisplayAttributes{
Value: 0,
},
}
ret.Fields["use_pss"] = &framework.FieldSchema{
Type: framework.TypeBool,
Default: false,
Description: `Whether or not to use PSS signatures when using a
RSA key-type issuer. Defaults to false.`,
}
return ret
}
@ -377,51 +322,7 @@ func (b *backend) pathSign(ctx context.Context, req *logical.Request, data *fram
// pathSignVerbatim issues a certificate from a submitted CSR, *not* subject to
// role restrictions
func (b *backend) pathSignVerbatim(ctx context.Context, req *logical.Request, data *framework.FieldData, role *roleEntry) (*logical.Response, error) {
entry := &roleEntry{
AllowLocalhost: true,
AllowAnyName: true,
AllowIPSANs: true,
AllowWildcardCertificates: new(bool),
EnforceHostnames: false,
KeyType: "any",
UseCSRCommonName: true,
UseCSRSANs: true,
AllowedOtherSANs: []string{"*"},
AllowedSerialNumbers: []string{"*"},
AllowedURISANs: []string{"*"},
AllowedUserIDs: []string{"*"},
CNValidations: []string{"disabled"},
GenerateLease: new(bool),
KeyUsage: data.Get("key_usage").([]string),
ExtKeyUsage: data.Get("ext_key_usage").([]string),
ExtKeyUsageOIDs: data.Get("ext_key_usage_oids").([]string),
SignatureBits: data.Get("signature_bits").(int),
UsePSS: data.Get("use_pss").(bool),
}
*entry.AllowWildcardCertificates = true
*entry.GenerateLease = false
if role != nil {
if role.TTL > 0 {
entry.TTL = role.TTL
}
if role.MaxTTL > 0 {
entry.MaxTTL = role.MaxTTL
}
if role.GenerateLease != nil {
*entry.GenerateLease = *role.GenerateLease
}
if role.NotBeforeDuration > 0 {
entry.NotBeforeDuration = role.NotBeforeDuration
}
entry.NoStore = role.NoStore
entry.Issuer = role.Issuer
}
if len(entry.Issuer) == 0 {
entry.Issuer = defaultRef
}
entry := buildSignVerbatimRole(data, role)
return b.pathIssueSignCert(ctx, req, data, entry, true, true)
}

View File

@ -1014,6 +1014,8 @@ func (b *backend) getRole(ctx context.Context, s logical.Storage, n string) (*ro
}
}
result.Name = n
return &result, nil
}
@ -1105,6 +1107,7 @@ func (b *backend) pathRoleCreate(ctx context.Context, req *logical.Request, data
NotBeforeDuration: time.Duration(data.Get("not_before_duration").(int)) * time.Second,
NotAfter: data.Get("not_after").(string),
Issuer: data.Get("issuer_ref").(string),
Name: name,
}
allowedOtherSANs := data.Get("allowed_other_sans").([]string)
@ -1510,6 +1513,8 @@ type roleEntry struct {
NotBeforeDuration time.Duration `json:"not_before_duration"`
NotAfter string `json:"not_after"`
Issuer string `json:"issuer"`
// Name is only set when the role has been stored, on the fly roles have a blank name
Name string `json:"-"`
}
func (r *roleEntry) ToResponseData() map[string]interface{} {