Skip to content

Commit fb30fcc

Browse files
committed
issuance: add new IncludeCRLDistributionPoints bool
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.
1 parent e0221b6 commit fb30fcc

File tree

6 files changed

+162
-30
lines changed

6 files changed

+162
-30
lines changed

ca/ca.go

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"crypto/x509"
1010
"crypto/x509/pkix"
1111
"encoding/asn1"
12-
"encoding/gob"
1312
"encoding/hex"
1413
"errors"
1514
"fmt"
@@ -195,7 +194,7 @@ func makeIssuerMaps(issuers []*issuance.Issuer) (issuerMaps, error) {
195194
// - CA1 returns the precertificate DER bytes and profile hash to the RA
196195
// - RA instructs CA2 to issue a final certificate, but CA2 does not contain a
197196
// profile corresponding to that hash and an issuance is prevented.
198-
func makeCertificateProfilesMap(defaultName string, profiles map[string]*issuance.ProfileConfig) (certProfilesMaps, error) {
197+
func makeCertificateProfilesMap(defaultName string, profiles map[string]*issuance.ProfileConfigNew) (certProfilesMaps, error) {
199198
if len(profiles) <= 0 {
200199
return certProfilesMaps{}, fmt.Errorf("must pass at least one certificate profile")
201200
}
@@ -215,20 +214,10 @@ func makeCertificateProfilesMap(defaultName string, profiles map[string]*issuanc
215214
return certProfilesMaps{}, err
216215
}
217216

218-
// gob can only encode exported fields, of which an issuance.Profile has
219-
// none. However, since we're already in a loop iteration having access
220-
// to the issuance.ProfileConfig used to generate the issuance.Profile,
221-
// we'll generate the hash from that.
222-
var encodedProfile bytes.Buffer
223-
enc := gob.NewEncoder(&encodedProfile)
224-
err = enc.Encode(profileConfig)
217+
hash, err := profileConfig.Hash()
225218
if err != nil {
226219
return certProfilesMaps{}, err
227220
}
228-
if len(encodedProfile.Bytes()) <= 0 {
229-
return certProfilesMaps{}, fmt.Errorf("certificate profile encoding returned 0 bytes")
230-
}
231-
hash := sha256.Sum256(encodedProfile.Bytes())
232221

233222
withID := certProfileWithID{
234223
name: name,
@@ -256,7 +245,7 @@ func NewCertificateAuthorityImpl(
256245
pa core.PolicyAuthority,
257246
boulderIssuers []*issuance.Issuer,
258247
defaultCertProfileName string,
259-
certificateProfiles map[string]*issuance.ProfileConfig,
248+
certificateProfiles map[string]*issuance.ProfileConfigNew,
260249
serialPrefix byte,
261250
maxNames int,
262251
keyPolicy goodkey.KeyPolicy,

ca/ca_test.go

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ type testCtx struct {
102102
ocsp *ocspImpl
103103
crl *crlImpl
104104
defaultCertProfileName string
105-
certProfiles map[string]*issuance.ProfileConfig
105+
certProfiles map[string]*issuance.ProfileConfigNew
106106
serialPrefix byte
107107
maxNames int
108108
boulderIssuers []*issuance.Issuer
@@ -153,14 +153,14 @@ func setup(t *testing.T) *testCtx {
153153
err = pa.LoadHostnamePolicyFile("../test/hostname-policy.yaml")
154154
test.AssertNotError(t, err, "Couldn't set hostname policy")
155155

156-
certProfiles := make(map[string]*issuance.ProfileConfig, 0)
157-
certProfiles["legacy"] = &issuance.ProfileConfig{
156+
certProfiles := make(map[string]*issuance.ProfileConfigNew, 0)
157+
certProfiles["legacy"] = &issuance.ProfileConfigNew{
158158
AllowMustStaple: true,
159159
MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 90},
160160
MaxValidityBackdate: config.Duration{Duration: time.Hour},
161161
IgnoredLints: []string{"w_subject_common_name_included"},
162162
}
163-
certProfiles["modern"] = &issuance.ProfileConfig{
163+
certProfiles["modern"] = &issuance.ProfileConfigNew{
164164
AllowMustStaple: true,
165165
OmitCommonName: true,
166166
OmitKeyEncipherment: true,
@@ -546,7 +546,7 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
546546
testCtx := setup(t)
547547
test.AssertEquals(t, len(testCtx.certProfiles), 2)
548548

549-
testProfile := issuance.ProfileConfig{
549+
testProfile := issuance.ProfileConfigNew{
550550
AllowMustStaple: false,
551551
MaxValidityPeriod: config.Duration{Duration: time.Hour * 24 * 90},
552552
MaxValidityBackdate: config.Duration{Duration: time.Hour},
@@ -560,7 +560,7 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
560560
testCases := []struct {
561561
name string
562562
defaultName string
563-
profileConfigs map[string]*issuance.ProfileConfig
563+
profileConfigs map[string]*issuance.ProfileConfigNew
564564
expectedErrSubstr string
565565
expectedProfiles []nameToHash
566566
}{
@@ -571,21 +571,21 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
571571
},
572572
{
573573
name: "no profiles",
574-
profileConfigs: map[string]*issuance.ProfileConfig{},
574+
profileConfigs: map[string]*issuance.ProfileConfigNew{},
575575
expectedErrSubstr: "at least one certificate profile",
576576
},
577577
{
578578
name: "no profile matching default name",
579579
defaultName: "default",
580-
profileConfigs: map[string]*issuance.ProfileConfig{
580+
profileConfigs: map[string]*issuance.ProfileConfigNew{
581581
"notDefault": &testProfile,
582582
},
583583
expectedErrSubstr: "profile object was not found for that name",
584584
},
585585
{
586586
name: "duplicate hash",
587587
defaultName: "default",
588-
profileConfigs: map[string]*issuance.ProfileConfig{
588+
profileConfigs: map[string]*issuance.ProfileConfigNew{
589589
"default": &testProfile,
590590
"default2": &testProfile,
591591
},
@@ -594,7 +594,7 @@ func TestMakeCertificateProfilesMap(t *testing.T) {
594594
{
595595
name: "empty profile config",
596596
defaultName: "empty",
597-
profileConfigs: map[string]*issuance.ProfileConfig{
597+
profileConfigs: map[string]*issuance.ProfileConfigNew{
598598
"empty": {},
599599
},
600600
expectedProfiles: []nameToHash{

cmd/boulder-ca/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ type Config struct {
4040

4141
// One of the profile names must match the value of
4242
// DefaultCertificateProfileName or boulder-ca will fail to start.
43-
CertProfiles map[string]*issuance.ProfileConfig `validate:"dive,keys,alphanum,min=1,max=32,endkeys,required_without=Profile,structonly"`
43+
CertProfiles map[string]*issuance.ProfileConfigNew `validate:"dive,keys,alphanum,min=1,max=32,endkeys,required_without=Profile,structonly"`
4444

4545
// TODO(#7159): Make this required once all live configs are using it.
4646
CRLProfile issuance.CRLProfileConfig `validate:"-"`

issuance/cert.go

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import (
66
"crypto/ecdsa"
77
"crypto/rand"
88
"crypto/rsa"
9+
"crypto/sha256"
910
"crypto/x509"
1011
"crypto/x509/pkix"
1112
"encoding/asn1"
13+
"encoding/gob"
1214
"encoding/json"
1315
"errors"
1416
"fmt"
@@ -28,7 +30,26 @@ import (
2830
"github.com/letsencrypt/boulder/precert"
2931
)
3032

31-
// ProfileConfig describes the certificate issuance constraints for all issuers.
33+
// ProfileConfig is a subset of ProfileConfigNew used for hashing.
34+
//
35+
// Deprecated: Use ProfileConfigNew instead.
36+
//
37+
// This struct exists for backwards-compatibility purposes when generating hashes
38+
// of profile configs.
39+
//
40+
// The CA uses a hash of the gob encoding of ProfileConfig to ensure precert
41+
// and final cert issuance use the exact same profile settings. Gob encodes all
42+
// fields, including zero values, which means adding fields immediately changes all
43+
// hashes, causing a deployability problem.
44+
//
45+
// To solve the deployability problem, we're switching to ASN.1 encoding. However,
46+
// while deploying that we still need the ability to hash old configs the same way
47+
// they've always been hashed. So this struct (with the same name it always had)
48+
// gets hashed, only when `ProfileConfigNew.IncludeCRLDistributionPoints` (the
49+
// newly added field) is false.
50+
//
51+
// Note that gob encodes the names of structs, not just their fields, so we needed
52+
// to retain the name as well.
3253
type ProfileConfig struct {
3354
// AllowMustStaple, when false, causes all IssuanceRequests which specify the
3455
// OCSP Must Staple extension to be rejected.
@@ -72,6 +93,78 @@ type ProfileConfig struct {
7293
Policies []PolicyConfig `validate:"-"`
7394
}
7495

96+
// ProfileConfigNew describes the certificate issuance constraints for all issuers.
97+
//
98+
// See ProfileConfig for why this is called "New".
99+
//
100+
// This struct gets hashed in the CA to allow matching up precert and final cert
101+
// issuance by the exact profile config. We compute the hash over an ASN.1 encoding
102+
// because ASN.1 encoding has a canonical form and can omit optional fields (which
103+
// allows for gracefully adding new fields without changing the hash of existing
104+
// profile configs). This struct does not get embedded into any certs, CRLs, or
105+
// other objects, and does not get signed; it's only used internally.
106+
type ProfileConfigNew struct {
107+
// AllowMustStaple, when false, causes all IssuanceRequests which specify the
108+
// OCSP Must Staple extension to be rejected.
109+
AllowMustStaple bool `asn1:"tag:1,optional"`
110+
111+
// OmitCommonName causes the CN field to be excluded from the resulting
112+
// certificate, regardless of its inclusion in the IssuanceRequest.
113+
OmitCommonName bool `asn1:"tag:2,optional"`
114+
// OmitKeyEncipherment causes the keyEncipherment bit to be omitted from the
115+
// Key Usage field of all certificates (instead of only from ECDSA certs).
116+
OmitKeyEncipherment bool `asn1:"tag:3,optional"`
117+
// OmitClientAuth causes the id-kp-clientAuth OID (TLS Client Authentication)
118+
// to be omitted from the EKU extension.
119+
OmitClientAuth bool `asn1:"tag:4,optional"`
120+
// OmitSKID causes the Subject Key Identifier extension to be omitted.
121+
OmitSKID bool `asn1:"tag:5,optional"`
122+
123+
MaxValidityPeriod config.Duration `asn1:"tag:6,optional"`
124+
MaxValidityBackdate config.Duration `asn1:"tag:7,optional"`
125+
126+
IncludeCRLDistributionPoints bool `asn1:"tag:8,optional"`
127+
128+
// LintConfig is a path to a zlint config file, which can be used to control
129+
// the behavior of zlint's "customizable lints".
130+
LintConfig string `asn1:"tag:9,optional"`
131+
// IgnoredLints is a list of lint names that we know will fail for this
132+
// profile, and which we know it is safe to ignore.
133+
IgnoredLints []string `asn1:"tag:10,optional"`
134+
}
135+
136+
func (pcn ProfileConfigNew) Hash() ([32]byte, error) {
137+
var encodedBytes []byte
138+
var err error
139+
if !pcn.IncludeCRLDistributionPoints {
140+
old := ProfileConfig{
141+
AllowMustStaple: pcn.AllowMustStaple,
142+
AllowCTPoison: false,
143+
AllowSCTList: false,
144+
AllowCommonName: false,
145+
OmitCommonName: pcn.OmitCommonName,
146+
OmitKeyEncipherment: pcn.OmitKeyEncipherment,
147+
OmitClientAuth: pcn.OmitClientAuth,
148+
OmitSKID: pcn.OmitSKID,
149+
MaxValidityPeriod: pcn.MaxValidityPeriod,
150+
MaxValidityBackdate: pcn.MaxValidityBackdate,
151+
LintConfig: pcn.LintConfig,
152+
IgnoredLints: pcn.IgnoredLints,
153+
Policies: nil,
154+
}
155+
var encoded bytes.Buffer
156+
enc := gob.NewEncoder(&encoded)
157+
err = enc.Encode(old)
158+
encodedBytes = encoded.Bytes()
159+
} else {
160+
encodedBytes, err = asn1.Marshal(pcn)
161+
}
162+
if err != nil {
163+
return [32]byte{}, err
164+
}
165+
return sha256.Sum256(encodedBytes), nil
166+
}
167+
75168
// PolicyConfig describes a policy
76169
type PolicyConfig struct {
77170
OID string `validate:"required"`
@@ -92,7 +185,7 @@ type Profile struct {
92185
}
93186

94187
// NewProfile converts the profile config into a usable profile.
95-
func NewProfile(profileConfig *ProfileConfig) (*Profile, error) {
188+
func NewProfile(profileConfig *ProfileConfigNew) (*Profile, error) {
96189
// The Baseline Requirements, Section 7.1.2.7, says that the notBefore time
97190
// must be "within 48 hours of the time of signing". We can be even stricter.
98191
if profileConfig.MaxValidityBackdate.Duration >= 24*time.Hour {

issuance/cert_test.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import (
1111
"crypto/x509/pkix"
1212
"encoding/asn1"
1313
"encoding/base64"
14+
"fmt"
1415
"testing"
1516
"time"
1617

1718
ct "github.com/google/certificate-transparency-go"
1819
"github.com/jmhodges/clock"
1920

21+
"github.com/letsencrypt/boulder/config"
2022
"github.com/letsencrypt/boulder/ctpolicy/loglist"
2123
"github.com/letsencrypt/boulder/linter"
2224
"github.com/letsencrypt/boulder/test"
@@ -779,7 +781,7 @@ func TestMismatchedProfiles(t *testing.T) {
779781

780782
// Create a new profile that differs slightly (no common name)
781783
pc = defaultProfileConfig()
782-
pc.AllowCommonName = false
784+
pc.OmitCommonName = false
783785
test.AssertNotError(t, err, "building test lint registry")
784786
noCNProfile, err := NewProfile(pc)
785787
test.AssertNotError(t, err, "NewProfile failed")
@@ -809,3 +811,51 @@ func TestMismatchedProfiles(t *testing.T) {
809811
test.AssertError(t, err, "preparing final cert issuance")
810812
test.AssertContains(t, err.Error(), "precert does not correspond to linted final cert")
811813
}
814+
815+
func TestProfileHash(t *testing.T) {
816+
// A profile without IncludeCRLDistributionPoints.
817+
// Hash calculated over the gob encoding of the old `ProfileConfig`.
818+
profile := ProfileConfigNew{
819+
IncludeCRLDistributionPoints: false,
820+
AllowMustStaple: true,
821+
OmitCommonName: true,
822+
OmitKeyEncipherment: false,
823+
OmitClientAuth: false,
824+
OmitSKID: true,
825+
MaxValidityPeriod: config.Duration{Duration: time.Hour},
826+
MaxValidityBackdate: config.Duration{Duration: time.Second},
827+
LintConfig: "example/config.toml",
828+
IgnoredLints: []string{"one", "two"},
829+
}
830+
hash, err := profile.Hash()
831+
if err != nil {
832+
t.Fatalf("hashing %+v: %s", profile, err)
833+
}
834+
expectedHash := "f6b5766141fdc066824e781347095ffb3c86fa97a174e21123a323a93b078f46"
835+
if expectedHash != fmt.Sprintf("%x", hash) {
836+
t.Errorf("%+v.Hash()=%x, want %s", profile, hash, expectedHash)
837+
}
838+
839+
// A profile _with_ IncludeCRLDistributionPoints.
840+
// Hash calculated over the ASN.1 encoding of the `ProfileConfigNew`.
841+
profile = ProfileConfigNew{
842+
IncludeCRLDistributionPoints: true,
843+
AllowMustStaple: true,
844+
OmitCommonName: true,
845+
OmitKeyEncipherment: false,
846+
OmitClientAuth: false,
847+
OmitSKID: true,
848+
MaxValidityPeriod: config.Duration{Duration: time.Hour},
849+
MaxValidityBackdate: config.Duration{Duration: time.Second},
850+
LintConfig: "example/config.toml",
851+
IgnoredLints: []string{"one", "two"},
852+
}
853+
hash, err = profile.Hash()
854+
if err != nil {
855+
t.Fatalf("hashing %+v: %s", profile, err)
856+
}
857+
expectedHash = "5939ea199deb3327d1b529da08d6fb07eb8de4c4cca9f6bf499d76da4b67d1e8"
858+
if expectedHash != fmt.Sprintf("%x", hash) {
859+
t.Errorf("%+v.Hash()=%x, want %s", profile, hash, expectedHash)
860+
}
861+
}

issuance/issuer_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import (
2222
"github.com/letsencrypt/boulder/test"
2323
)
2424

25-
func defaultProfileConfig() *ProfileConfig {
26-
return &ProfileConfig{
25+
func defaultProfileConfig() *ProfileConfigNew {
26+
return &ProfileConfigNew{
2727
AllowMustStaple: true,
2828
MaxValidityPeriod: config.Duration{Duration: time.Hour},
2929
MaxValidityBackdate: config.Duration{Duration: time.Hour},

0 commit comments

Comments
 (0)