issuance: add new IncludeCRLDistributionPoints bool (#7985)

To achieve this without breaking hashes of deployed configs, create a
ProfileConfigNew containing the new field (and removing some deprecated
fields).

Move the CA's profile-hashing logic into the `issuance` package, and
gate it on the presence of IncludeCRLDistributionPoints. If that field
is false (the default), create an instance of the old `ProfileConfig`
with the appropriate values and encode/hash that instead.

Note: the IncludeCRLDistributionPoints field does not yet control any
behavior. That will be part of #7974.

Part of #7094
This commit is contained in:
Jacob Hoffman-Andrews 2025-01-30 11:48:54 -08:00 committed by GitHub
parent c7da1201db
commit d93f0c316a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 178 additions and 34 deletions

View File

@ -9,7 +9,6 @@ import (
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/asn1" "encoding/asn1"
"encoding/gob"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
@ -195,7 +194,7 @@ func makeIssuerMaps(issuers []*issuance.Issuer) (issuerMaps, error) {
// - CA1 returns the precertificate DER bytes and profile hash to the RA // - CA1 returns the precertificate DER bytes and profile hash to the RA
// - RA instructs CA2 to issue a final certificate, but CA2 does not contain a // - RA instructs CA2 to issue a final certificate, but CA2 does not contain a
// profile corresponding to that hash and an issuance is prevented. // profile corresponding to that hash and an issuance is prevented.
func makeCertificateProfilesMap(defaultName string, profiles map[string]*issuance.ProfileConfig) (certProfilesMaps, error) { func makeCertificateProfilesMap(defaultName string, profiles map[string]*issuance.ProfileConfigNew) (certProfilesMaps, error) {
if len(profiles) <= 0 { if len(profiles) <= 0 {
return certProfilesMaps{}, fmt.Errorf("must pass at least one certificate profile") return certProfilesMaps{}, fmt.Errorf("must pass at least one certificate profile")
} }
@ -215,20 +214,7 @@ func makeCertificateProfilesMap(defaultName string, profiles map[string]*issuanc
return certProfilesMaps{}, err return certProfilesMaps{}, err
} }
// gob can only encode exported fields, of which an issuance.Profile has hash := profile.Hash()
// none. However, since we're already in a loop iteration having access
// to the issuance.ProfileConfig used to generate the issuance.Profile,
// we'll generate the hash from that.
var encodedProfile bytes.Buffer
enc := gob.NewEncoder(&encodedProfile)
err = enc.Encode(profileConfig)
if err != nil {
return certProfilesMaps{}, err
}
if len(encodedProfile.Bytes()) <= 0 {
return certProfilesMaps{}, fmt.Errorf("certificate profile encoding returned 0 bytes")
}
hash := sha256.Sum256(encodedProfile.Bytes())
withID := certProfileWithID{ withID := certProfileWithID{
name: name, name: name,
@ -237,7 +223,6 @@ func makeCertificateProfilesMap(defaultName string, profiles map[string]*issuanc
} }
profilesByName[name] = &withID profilesByName[name] = &withID
_, found := profilesByHash[hash] _, found := profilesByHash[hash]
if found { if found {
return certProfilesMaps{}, fmt.Errorf("duplicate certificate profile hash %d", hash) return certProfilesMaps{}, fmt.Errorf("duplicate certificate profile hash %d", hash)
@ -256,7 +241,7 @@ func NewCertificateAuthorityImpl(
pa core.PolicyAuthority, pa core.PolicyAuthority,
boulderIssuers []*issuance.Issuer, boulderIssuers []*issuance.Issuer,
defaultCertProfileName string, defaultCertProfileName string,
certificateProfiles map[string]*issuance.ProfileConfig, certificateProfiles map[string]*issuance.ProfileConfigNew,
serialPrefix byte, serialPrefix byte,
maxNames int, maxNames int,
keyPolicy goodkey.KeyPolicy, keyPolicy goodkey.KeyPolicy,

View File

@ -102,7 +102,7 @@ type testCtx struct {
ocsp *ocspImpl ocsp *ocspImpl
crl *crlImpl crl *crlImpl
defaultCertProfileName string defaultCertProfileName string
certProfiles map[string]*issuance.ProfileConfig certProfiles map[string]*issuance.ProfileConfigNew
serialPrefix byte serialPrefix byte
maxNames int maxNames int
boulderIssuers []*issuance.Issuer boulderIssuers []*issuance.Issuer
@ -153,14 +153,14 @@ func setup(t *testing.T) *testCtx {
err = pa.LoadHostnamePolicyFile("../test/hostname-policy.yaml") err = pa.LoadHostnamePolicyFile("../test/hostname-policy.yaml")
test.AssertNotError(t, err, "Couldn't set hostname policy") test.AssertNotError(t, err, "Couldn't set hostname policy")
certProfiles := make(map[string]*issuance.ProfileConfig, 0) certProfiles := make(map[string]*issuance.ProfileConfigNew, 0)
certProfiles["legacy"] = &issuance.ProfileConfig{ certProfiles["legacy"] = &issuance.ProfileConfigNew{
AllowMustStaple: true, AllowMustStaple: true,
MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 90}, MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 90},
MaxValidityBackdate: config.Duration{Duration: time.Hour}, MaxValidityBackdate: config.Duration{Duration: time.Hour},
IgnoredLints: []string{"w_subject_common_name_included"}, IgnoredLints: []string{"w_subject_common_name_included"},
} }
certProfiles["modern"] = &issuance.ProfileConfig{ certProfiles["modern"] = &issuance.ProfileConfigNew{
AllowMustStaple: true, AllowMustStaple: true,
OmitCommonName: true, OmitCommonName: true,
OmitKeyEncipherment: true, OmitKeyEncipherment: true,
@ -546,7 +546,7 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
testCtx := setup(t) testCtx := setup(t)
test.AssertEquals(t, len(testCtx.certProfiles), 2) test.AssertEquals(t, len(testCtx.certProfiles), 2)
testProfile := issuance.ProfileConfig{ testProfile := issuance.ProfileConfigNew{
AllowMustStaple: false, AllowMustStaple: false,
MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 90}, MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 90},
MaxValidityBackdate: config.Duration{Duration: time.Hour}, MaxValidityBackdate: config.Duration{Duration: time.Hour},
@ -560,7 +560,7 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
testCases := []struct { testCases := []struct {
name string name string
defaultName string defaultName string
profileConfigs map[string]*issuance.ProfileConfig profileConfigs map[string]*issuance.ProfileConfigNew
expectedErrSubstr string expectedErrSubstr string
expectedProfiles []nameToHash expectedProfiles []nameToHash
}{ }{
@ -571,13 +571,13 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
}, },
{ {
name: "no profiles", name: "no profiles",
profileConfigs: map[string]*issuance.ProfileConfig{}, profileConfigs: map[string]*issuance.ProfileConfigNew{},
expectedErrSubstr: "at least one certificate profile", expectedErrSubstr: "at least one certificate profile",
}, },
{ {
name: "no profile matching default name", name: "no profile matching default name",
defaultName: "default", defaultName: "default",
profileConfigs: map[string]*issuance.ProfileConfig{ profileConfigs: map[string]*issuance.ProfileConfigNew{
"notDefault": &testProfile, "notDefault": &testProfile,
}, },
expectedErrSubstr: "profile object was not found for that name", expectedErrSubstr: "profile object was not found for that name",
@ -585,7 +585,7 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
{ {
name: "duplicate hash", name: "duplicate hash",
defaultName: "default", defaultName: "default",
profileConfigs: map[string]*issuance.ProfileConfig{ profileConfigs: map[string]*issuance.ProfileConfigNew{
"default": &testProfile, "default": &testProfile,
"default2": &testProfile, "default2": &testProfile,
}, },
@ -594,7 +594,7 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
{ {
name: "empty profile config", name: "empty profile config",
defaultName: "empty", defaultName: "empty",
profileConfigs: map[string]*issuance.ProfileConfig{ profileConfigs: map[string]*issuance.ProfileConfigNew{
"empty": {}, "empty": {},
}, },
expectedProfiles: []nameToHash{ expectedProfiles: []nameToHash{

View File

@ -40,7 +40,7 @@ type Config struct {
// One of the profile names must match the value of // One of the profile names must match the value of
// DefaultCertificateProfileName or boulder-ca will fail to start. // DefaultCertificateProfileName or boulder-ca will fail to start.
CertProfiles map[string]*issuance.ProfileConfig `validate:"dive,keys,alphanum,min=1,max=32,endkeys,required_without=Profile,structonly"` CertProfiles map[string]*issuance.ProfileConfigNew `validate:"dive,keys,alphanum,min=1,max=32,endkeys,required_without=Profile,structonly"`
// TODO(#7159): Make this required once all live configs are using it. // TODO(#7159): Make this required once all live configs are using it.
CRLProfile issuance.CRLProfileConfig `validate:"-"` CRLProfile issuance.CRLProfileConfig `validate:"-"`

View File

@ -6,9 +6,11 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/sha256"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/asn1" "encoding/asn1"
"encoding/gob"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@ -28,7 +30,26 @@ import (
"github.com/letsencrypt/boulder/precert" "github.com/letsencrypt/boulder/precert"
) )
// ProfileConfig describes the certificate issuance constraints for all issuers. // ProfileConfig is a subset of ProfileConfigNew used for hashing.
//
// Deprecated: Use ProfileConfigNew instead.
//
// This struct exists for backwards-compatibility purposes when generating hashes
// of profile configs.
//
// The CA uses a hash of the gob encoding of ProfileConfig to ensure precert
// and final cert issuance use the exact same profile settings. Gob encodes all
// fields, including zero values, which means adding fields immediately changes all
// hashes, causing a deployability problem. It also encodes the struct name.
//
// To solve the deployability problem, we're switching to ASN.1 encoding. However,
// while deploying that we still need the ability to hash old configs the same way
// they've always been hashed. So this struct (with the same name it always had)
// gets hashed, only when `ProfileConfigNew.IncludeCRLDistributionPoints` (the
// newly added field) is false.
//
// Note that gob encodes the names of structs, not just their fields, so we needed
// to retain the name as well.
type ProfileConfig struct { type ProfileConfig struct {
// AllowMustStaple, when false, causes all IssuanceRequests which specify the // AllowMustStaple, when false, causes all IssuanceRequests which specify the
// OCSP Must Staple extension to be rejected. // OCSP Must Staple extension to be rejected.
@ -72,6 +93,82 @@ type ProfileConfig struct {
Policies []PolicyConfig `validate:"-"` Policies []PolicyConfig `validate:"-"`
} }
// ProfileConfigNew describes the certificate issuance constraints for all issuers.
//
// See ProfileConfig for why this is called "New".
//
// This struct gets hashed in the CA to allow matching up precert and final cert
// issuance by the exact profile config. We compute the hash over an ASN.1 encoding
// because ASN.1 encoding has a canonical form and can omit optional fields (which
// allows for gracefully adding new fields without changing the hash of existing
// profile configs). This struct does not get embedded into any certs, CRLs, or
// other objects, and does not get signed; it's only used internally.
//
// Note: even though these fields have encoding instructions (tag:N), they will
// be encoded in the order they appear in the struct, so do not reorder them.
type ProfileConfigNew struct {
// AllowMustStaple, when false, causes all IssuanceRequests which specify the
// OCSP Must Staple extension to be rejected.
AllowMustStaple bool `asn1:"tag:1,optional"`
// OmitCommonName causes the CN field to be excluded from the resulting
// certificate, regardless of its inclusion in the IssuanceRequest.
OmitCommonName bool `asn1:"tag:2,optional"`
// OmitKeyEncipherment causes the keyEncipherment bit to be omitted from the
// Key Usage field of all certificates (instead of only from ECDSA certs).
OmitKeyEncipherment bool `asn1:"tag:3,optional"`
// OmitClientAuth causes the id-kp-clientAuth OID (TLS Client Authentication)
// to be omitted from the EKU extension.
OmitClientAuth bool `asn1:"tag:4,optional"`
// OmitSKID causes the Subject Key Identifier extension to be omitted.
OmitSKID bool `asn1:"tag:5,optional"`
// IncludeCRLDistributionPoints causes the CRLDistributionPoints extension to
// be added to all certificates issued by this profile.
IncludeCRLDistributionPoints bool `asn1:"tag:6,optional"`
MaxValidityPeriod config.Duration `asn1:"tag:7,optional"`
MaxValidityBackdate config.Duration `asn1:"tag:8,optional"`
// LintConfig is a path to a zlint config file, which can be used to control
// the behavior of zlint's "customizable lints".
LintConfig string `asn1:"tag:9,optional"`
// IgnoredLints is a list of lint names that we know will fail for this
// profile, and which we know it is safe to ignore.
IgnoredLints []string `asn1:"tag:10,optional"`
}
func (pcn ProfileConfigNew) Hash() ([32]byte, error) {
var encodedBytes []byte
var err error
if !pcn.IncludeCRLDistributionPoints {
old := ProfileConfig{
AllowMustStaple: pcn.AllowMustStaple,
AllowCTPoison: false,
AllowSCTList: false,
AllowCommonName: false,
OmitCommonName: pcn.OmitCommonName,
OmitKeyEncipherment: pcn.OmitKeyEncipherment,
OmitClientAuth: pcn.OmitClientAuth,
OmitSKID: pcn.OmitSKID,
MaxValidityPeriod: pcn.MaxValidityPeriod,
MaxValidityBackdate: pcn.MaxValidityBackdate,
LintConfig: pcn.LintConfig,
IgnoredLints: pcn.IgnoredLints,
Policies: nil,
}
var encoded bytes.Buffer
enc := gob.NewEncoder(&encoded)
err = enc.Encode(old)
encodedBytes = encoded.Bytes()
} else {
encodedBytes, err = asn1.Marshal(pcn)
}
if err != nil {
return [32]byte{}, err
}
return sha256.Sum256(encodedBytes), nil
}
// PolicyConfig describes a policy // PolicyConfig describes a policy
type PolicyConfig struct { type PolicyConfig struct {
OID string `validate:"required"` OID string `validate:"required"`
@ -89,10 +186,12 @@ type Profile struct {
maxValidity time.Duration maxValidity time.Duration
lints lint.Registry lints lint.Registry
hash [32]byte
} }
// NewProfile converts the profile config into a usable profile. // NewProfile converts the profile config into a usable profile.
func NewProfile(profileConfig *ProfileConfig) (*Profile, error) { func NewProfile(profileConfig *ProfileConfigNew) (*Profile, error) {
// The Baseline Requirements, Section 7.1.2.7, says that the notBefore time // The Baseline Requirements, Section 7.1.2.7, says that the notBefore time
// must be "within 48 hours of the time of signing". We can be even stricter. // must be "within 48 hours of the time of signing". We can be even stricter.
if profileConfig.MaxValidityBackdate.Duration >= 24*time.Hour { if profileConfig.MaxValidityBackdate.Duration >= 24*time.Hour {
@ -113,6 +212,11 @@ func NewProfile(profileConfig *ProfileConfig) (*Profile, error) {
lints.SetConfiguration(lintconfig) lints.SetConfiguration(lintconfig)
} }
hash, err := profileConfig.Hash()
if err != nil {
return nil, err
}
sp := &Profile{ sp := &Profile{
allowMustStaple: profileConfig.AllowMustStaple, allowMustStaple: profileConfig.AllowMustStaple,
omitCommonName: profileConfig.OmitCommonName, omitCommonName: profileConfig.OmitCommonName,
@ -122,11 +226,16 @@ func NewProfile(profileConfig *ProfileConfig) (*Profile, error) {
maxBackdate: profileConfig.MaxValidityBackdate.Duration, maxBackdate: profileConfig.MaxValidityBackdate.Duration,
maxValidity: profileConfig.MaxValidityPeriod.Duration, maxValidity: profileConfig.MaxValidityPeriod.Duration,
lints: lints, lints: lints,
hash: hash,
} }
return sp, nil return sp, nil
} }
func (p *Profile) Hash() [32]byte {
return p.hash
}
// GenerateValidity returns a notBefore/notAfter pair bracketing the input time, // GenerateValidity returns a notBefore/notAfter pair bracketing the input time,
// based on the profile's configured backdate and validity. // based on the profile's configured backdate and validity.
func (p *Profile) GenerateValidity(now time.Time) (time.Time, time.Time) { func (p *Profile) GenerateValidity(now time.Time) (time.Time, time.Time) {

View File

@ -11,12 +11,14 @@ import (
"crypto/x509/pkix" "crypto/x509/pkix"
"encoding/asn1" "encoding/asn1"
"encoding/base64" "encoding/base64"
"fmt"
"testing" "testing"
"time" "time"
ct "github.com/google/certificate-transparency-go" ct "github.com/google/certificate-transparency-go"
"github.com/jmhodges/clock" "github.com/jmhodges/clock"
"github.com/letsencrypt/boulder/config"
"github.com/letsencrypt/boulder/ctpolicy/loglist" "github.com/letsencrypt/boulder/ctpolicy/loglist"
"github.com/letsencrypt/boulder/linter" "github.com/letsencrypt/boulder/linter"
"github.com/letsencrypt/boulder/test" "github.com/letsencrypt/boulder/test"
@ -779,7 +781,7 @@ func TestMismatchedProfiles(t *testing.T) {
// Create a new profile that differs slightly (no common name) // Create a new profile that differs slightly (no common name)
pc = defaultProfileConfig() pc = defaultProfileConfig()
pc.AllowCommonName = false pc.OmitCommonName = false
test.AssertNotError(t, err, "building test lint registry") test.AssertNotError(t, err, "building test lint registry")
noCNProfile, err := NewProfile(pc) noCNProfile, err := NewProfile(pc)
test.AssertNotError(t, err, "NewProfile failed") test.AssertNotError(t, err, "NewProfile failed")
@ -809,3 +811,51 @@ func TestMismatchedProfiles(t *testing.T) {
test.AssertError(t, err, "preparing final cert issuance") test.AssertError(t, err, "preparing final cert issuance")
test.AssertContains(t, err.Error(), "precert does not correspond to linted final cert") test.AssertContains(t, err.Error(), "precert does not correspond to linted final cert")
} }
func TestProfileHash(t *testing.T) {
// A profile without IncludeCRLDistributionPoints.
// Hash calculated over the gob encoding of the old `ProfileConfig`.
profile := ProfileConfigNew{
IncludeCRLDistributionPoints: false,
AllowMustStaple: true,
OmitCommonName: true,
OmitKeyEncipherment: false,
OmitClientAuth: false,
OmitSKID: true,
MaxValidityPeriod: config.Duration{Duration: time.Hour},
MaxValidityBackdate: config.Duration{Duration: time.Second},
LintConfig: "example/config.toml",
IgnoredLints: []string{"one", "two"},
}
hash, err := profile.Hash()
if err != nil {
t.Fatalf("hashing %+v: %s", profile, err)
}
expectedHash := "f6b5766141fdc066824e781347095ffb3c86fa97a174e21123a323a93b078f46"
if expectedHash != fmt.Sprintf("%x", hash) {
t.Errorf("%+v.Hash()=%x, want %s", profile, hash, expectedHash)
}
// A profile _with_ IncludeCRLDistributionPoints.
// Hash calculated over the ASN.1 encoding of the `ProfileConfigNew`.
profile = ProfileConfigNew{
IncludeCRLDistributionPoints: true,
AllowMustStaple: true,
OmitCommonName: true,
OmitKeyEncipherment: false,
OmitClientAuth: false,
OmitSKID: true,
MaxValidityPeriod: config.Duration{Duration: time.Hour},
MaxValidityBackdate: config.Duration{Duration: time.Second},
LintConfig: "example/config.toml",
IgnoredLints: []string{"one", "two"},
}
hash, err = profile.Hash()
if err != nil {
t.Fatalf("hashing %+v: %s", profile, err)
}
expectedHash = "d2a6c9f0aa37d2ac0b15476cb6e0ae9b98ba59b1321d8d6da26efc620581c53d"
if expectedHash != fmt.Sprintf("%x", hash) {
t.Errorf("%+v.Hash()=%x, want %s", profile, hash, expectedHash)
}
}

View File

@ -22,8 +22,8 @@ import (
"github.com/letsencrypt/boulder/test" "github.com/letsencrypt/boulder/test"
) )
func defaultProfileConfig() *ProfileConfig { func defaultProfileConfig() *ProfileConfigNew {
return &ProfileConfig{ return &ProfileConfigNew{
AllowMustStaple: true, AllowMustStaple: true,
MaxValidityPeriod: config.Duration{Duration: time.Hour}, MaxValidityPeriod: config.Duration{Duration: time.Hour},
MaxValidityBackdate: config.Duration{Duration: time.Hour}, MaxValidityBackdate: config.Duration{Duration: time.Hour},