Skip to content

Commit af5bd96

Browse files
authored
Create scaffolding for configuring lints with issuers (#8923)
Create the ability for lints to be configured with either an issuer certificate (useful for everything but self-signed roots), or an pre-existing certificate (useful for cross-signs), or both. Because zlint expects configs to be TOML strings, build some infrastructure that is capable of serializing and deserializing these configs, and augmenting them at runtime. This lets us combine the actual zlint config (which includes things like ignored lints and how to connect to PKIMetal) with the dynamic issuer or existing cert config. Have the CA and Ceremony tool correctly configure zlint with the relevant additional certs. No lints use this yet, but our CP/CPS lints will soon.
1 parent 16e99c3 commit af5bd96

9 files changed

Lines changed: 436 additions & 24 deletions

File tree

cmd/ceremony/main.go

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,15 @@ type lintCert *x509.Certificate
4646
// template certificate signed by a given issuer and returns a *lintCert or an
4747
// error. The lint certificate is linted prior to being returned. The public key
4848
// from the just issued lint certificate is checked by the GoodKey package.
49-
func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey crypto.PublicKey, signer crypto.Signer, skipLints []string) (lintCert, error) {
50-
bytes, err := linter.Check(tbs, subjectPubKey, issuer, signer, skipLints)
49+
// When cross-signing, existing is the pre-existing certificate of the CA being
50+
// cross-signed, allowing the CP/CPS profile lints to check correspondence with
51+
// it; it is nil otherwise.
52+
func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey crypto.PublicKey, signer crypto.Signer, existing *x509.Certificate, skipLints []string) (lintCert, error) {
53+
lintConfig, err := linter.Config{}.WithExisting(existing)
54+
if err != nil {
55+
return nil, fmt.Errorf("unable to create lint config: %w", err)
56+
}
57+
bytes, err := linter.Check(tbs, subjectPubKey, issuer, signer, lintConfig, skipLints)
5158
if err != nil {
5259
return nil, fmt.Errorf("certificate failed pre-issuance lint: %w", err)
5360
}
@@ -66,8 +73,10 @@ func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey
6673
// postIssuanceLinting performs post-issuance linting on the raw bytes of a
6774
// given certificate with the same set of lints as
6875
// issueLintCertAndPerformLinting. The public key is also checked by the GoodKey
69-
// package.
70-
func postIssuanceLinting(fc *x509.Certificate, skipLints []string) error {
76+
// package. The issuer and existing certificates, when non-nil, are supplied to
77+
// the CP/CPS profile lints so that they can perform their correspondence
78+
// checks.
79+
func postIssuanceLinting(fc, issuer, existing *x509.Certificate, skipLints []string) error {
7180
if fc == nil {
7281
return fmt.Errorf("certificate was not provided")
7382
}
@@ -77,7 +86,15 @@ func postIssuanceLinting(fc *x509.Certificate, skipLints []string) error {
7786
// lint. This should be treated as ZLint rejecting the certificate
7887
return fmt.Errorf("unable to parse certificate: %s", err)
7988
}
80-
registry, err := linter.NewRegistry(skipLints)
89+
lintConfig, err := linter.Config{}.WithIssuer(issuer)
90+
if err != nil {
91+
return fmt.Errorf("unable to create lint config: %s", err)
92+
}
93+
lintConfig, err = lintConfig.WithExisting(existing)
94+
if err != nil {
95+
return fmt.Errorf("unable to create lint config: %s", err)
96+
}
97+
registry, err := linter.NewRegistryWithConfig(skipLints, lintConfig)
8198
if err != nil {
8299
return fmt.Errorf("unable to create zlint registry: %s", err)
83100
}
@@ -586,15 +603,15 @@ func rootCeremony(configBytes []byte) error {
586603
if err != nil {
587604
return fmt.Errorf("failed to create certificate profile: %s", err)
588605
}
589-
lintCert, err := issueLintCertAndPerformLinting(template, template, keyInfo.key, signer, config.SkipLints)
606+
lintCert, err := issueLintCertAndPerformLinting(template, template, keyInfo.key, signer, nil, config.SkipLints)
590607
if err != nil {
591608
return err
592609
}
593610
finalCert, err := signAndWriteCert(template, template, lintCert, keyInfo.key, signer, config.Outputs.CertificatePath)
594611
if err != nil {
595612
return err
596613
}
597-
err = postIssuanceLinting(finalCert, config.SkipLints)
614+
err = postIssuanceLinting(finalCert, nil, nil, config.SkipLints)
598615
if err != nil {
599616
return err
600617
}
@@ -631,7 +648,7 @@ func intermediateCeremony(configBytes []byte) error {
631648
return fmt.Errorf("failed to create certificate profile: %s", err)
632649
}
633650
template.AuthorityKeyId = issuer.SubjectKeyId
634-
lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, config.SkipLints)
651+
lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, nil, config.SkipLints)
635652
if err != nil {
636653
return err
637654
}
@@ -646,7 +663,7 @@ func intermediateCeremony(configBytes []byte) error {
646663
if !bytes.Equal(lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) {
647664
return fmt.Errorf("mismatch between lintCert and finalCert RawTBSCertificate DER bytes: \"%x\" != \"%x\"", lintCert.RawTBSCertificate, finalCert.RawTBSCertificate)
648665
}
649-
err = postIssuanceLinting(finalCert, config.SkipLints)
666+
err = postIssuanceLinting(finalCert, issuer, nil, config.SkipLints)
650667
if err != nil {
651668
return err
652669
}
@@ -687,7 +704,7 @@ func crossCertCeremony(configBytes []byte) error {
687704
return fmt.Errorf("failed to create certificate profile: %s", err)
688705
}
689706
template.AuthorityKeyId = issuer.SubjectKeyId
690-
lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, config.SkipLints)
707+
lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, toBeCrossSigned, config.SkipLints)
691708
if err != nil {
692709
return err
693710
}
@@ -748,7 +765,7 @@ func crossCertCeremony(configBytes []byte) error {
748765
if !bytes.Equal(lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) {
749766
return fmt.Errorf("mismatch between lintCert and finalCert RawTBSCertificate DER bytes: \"%x\" != \"%x\"", lintCert.RawTBSCertificate, finalCert.RawTBSCertificate)
750767
}
751-
err = postIssuanceLinting(finalCert, config.SkipLints)
768+
err = postIssuanceLinting(finalCert, issuer, toBeCrossSigned, config.SkipLints)
752769
if err != nil {
753770
return err
754771
}

cmd/ceremony/main_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,7 +1292,7 @@ func TestSignAndWriteNoLintCert(t *testing.T) {
12921292

12931293
func TestPostIssuanceLinting(t *testing.T) {
12941294
clk := clock.New()
1295-
err := postIssuanceLinting(nil, nil)
1295+
err := postIssuanceLinting(nil, nil, nil, nil)
12961296
test.AssertError(t, err, "should have failed because no certificate was provided")
12971297

12981298
testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
@@ -1306,6 +1306,6 @@ func TestPostIssuanceLinting(t *testing.T) {
13061306
test.AssertNotError(t, err, "unable to create certificate")
13071307
parsedCert, err := x509.ParseCertificate(certDer)
13081308
test.AssertNotError(t, err, "unable to parse DER bytes")
1309-
err = postIssuanceLinting(parsedCert, nil)
1309+
err = postIssuanceLinting(parsedCert, nil, nil, nil)
13101310
test.AssertNotError(t, err, "should not have errored")
13111311
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ require (
2222
github.com/miekg/dns v1.1.62
2323
github.com/miekg/pkcs11 v1.1.2
2424
github.com/nxadm/tail v1.4.11
25+
github.com/pelletier/go-toml v1.9.5
2526
github.com/prometheus/client_golang v1.22.0
2627
github.com/prometheus/client_model v0.6.1
2728
github.com/redis/go-redis/extra/redisotel/v9 v9.5.3
@@ -76,7 +77,6 @@ require (
7677
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect
7778
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect
7879
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
79-
github.com/pelletier/go-toml v1.9.5 // indirect
8080
github.com/poy/onpar v1.1.2 // indirect
8181
github.com/prometheus/common v0.62.0 // indirect
8282
github.com/prometheus/procfs v0.15.1 // indirect

issuance/cert.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,14 @@ type Profile struct {
7777

7878
maxCertificateSize int
7979

80+
// lints is the registry of lints to run against certificates issued under
81+
// this profile. It carries no lint configuration of its own: at issuance
82+
// time it is combined with a configuration derived from lintConfig.
8083
lints lint.Registry
84+
// lintConfig is the in-memory contents of this profile's zlint config
85+
// file. At issuance time it is augmented with the issuing Issuer's
86+
// certificate via WithIssuer.
87+
lintConfig linter.Config
8188
}
8289

8390
// NewProfile converts the profile config into a usable profile.
@@ -96,11 +103,9 @@ func NewProfile(profileConfig ProfileConfig) (*Profile, error) {
96103

97104
lints, err := linter.NewRegistry(profileConfig.IgnoredLints)
98105
cmd.FailOnError(err, "Failed to create zlint registry")
99-
if profileConfig.LintConfig != "" {
100-
lintconfig, err := lint.NewConfigFromFile(profileConfig.LintConfig)
101-
cmd.FailOnError(err, "Failed to load zlint config file")
102-
lints.SetConfiguration(lintconfig)
103-
}
106+
107+
lintConfig, err := linter.LoadConfigFile(profileConfig.LintConfig)
108+
cmd.FailOnError(err, "Failed to load zlint config file")
104109

105110
sp := &Profile{
106111
omitCommonName: profileConfig.OmitCommonName,
@@ -111,6 +116,7 @@ func NewProfile(profileConfig ProfileConfig) (*Profile, error) {
111116
maxValidity: profileConfig.MaxValidityPeriod.Duration,
112117
maxCertificateSize: profileConfig.MaxCertificateSize,
113118
lints: lints,
119+
lintConfig: lintConfig,
114120
}
115121

116122
return sp, nil
@@ -369,7 +375,11 @@ func (i *Issuer) Prepare(prof *Profile, req *IssuanceRequest) ([]byte, *issuance
369375

370376
// check that the tbsCertificate is properly formed by signing it
371377
// with a throwaway key and then linting it using zlint
372-
lintCertBytes, err := i.Linter.Check(template, req.PublicKey.PublicKey, prof.lints)
378+
lintConfig, err := prof.lintConfig.WithIssuer(i.Cert.Certificate)
379+
if err != nil {
380+
return nil, nil, fmt.Errorf("building lint config: %w", err)
381+
}
382+
lintCertBytes, err := i.Linter.Check(template, req.PublicKey.PublicKey, prof.lints, lintConfig)
373383
if err != nil {
374384
return nil, nil, fmt.Errorf("tbsCertificate linting failed: %w", err)
375385
}

linter/config.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package linter
2+
3+
import (
4+
"crypto/x509"
5+
"encoding/pem"
6+
"fmt"
7+
"os"
8+
9+
"github.com/pelletier/go-toml"
10+
"github.com/zmap/zlint/v3/lint"
11+
12+
"github.com/letsencrypt/boulder/linter/lints/cpcps"
13+
)
14+
15+
// Config is a validated, in-memory zlint lint configuration. The zero value
16+
// is an empty configuration.
17+
type Config struct {
18+
// The zlint package only accepts configuration as TOML (and re-parses it on
19+
// every lint pass anyway), so we store the config in zlint's format.
20+
toml string
21+
}
22+
23+
// LoadConfigFile reads and validates a zlint TOML config file. An empty path
24+
// yields an empty Config. It is an error for the file to set the shared
25+
// configuration keys read by the CP/CPS profile lints: those are derived from
26+
// certificates via WithIssuer and WithExisting instead.
27+
func LoadConfigFile(path string) (Config, error) {
28+
if path == "" {
29+
return Config{}, nil
30+
}
31+
contents, err := os.ReadFile(path)
32+
if err != nil {
33+
return Config{}, fmt.Errorf("failed to read zlint config file: %w", err)
34+
}
35+
tree, err := toml.LoadBytes(contents)
36+
if err != nil {
37+
return Config{}, fmt.Errorf("failed to parse zlint config file %q: %w", path, err)
38+
}
39+
for _, key := range []string{cpcps.IssuerCertificateConfigKey, cpcps.ExistingCertificateConfigKey} {
40+
if tree.HasPath([]string{cpcps.GlobalConfigNamespace, key}) {
41+
return Config{}, fmt.Errorf("zlint config file %q must not set %s.%s: it is derived from the issuer certificate", path, cpcps.GlobalConfigNamespace, key)
42+
}
43+
}
44+
return Config{toml: string(contents)}, nil
45+
}
46+
47+
// WithIssuer returns a copy of the Config with a stanza holding the PEM of the
48+
// issuer's certificate. This is necessary for the CP/CPS profile lints, which
49+
// check that certain fields of the certificate being linted match the issuer. A
50+
// nil issuer, or one with no raw DER bytes (i.e. a to-be-signed template rather
51+
// than a real certificate, as in a self-signed root ceremony), returns the
52+
// Config unchanged.
53+
func (c Config) WithIssuer(issuer *x509.Certificate) (Config, error) {
54+
if issuer == nil || len(issuer.Raw) == 0 {
55+
return c, nil
56+
}
57+
issuerPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuer.Raw}))
58+
return c.set(cpcps.IssuerCertificateConfigKey, issuerPEM)
59+
}
60+
61+
// WithExisting returns a copy of the Config with a stanza holding the PEM of
62+
// the pre-existing certificate which is being cross-signed. This is necessary
63+
// for the CP/CPS Cross-Certified Subordinate CA Certificate lint. It is only
64+
// used by the ceremony tool. A nil existing certificate, or one with no raw DER
65+
// bytes, returns the Config unchanged.
66+
func (c Config) WithExisting(existing *x509.Certificate) (Config, error) {
67+
if existing == nil || len(existing.Raw) == 0 {
68+
return c, nil
69+
}
70+
existingPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: existing.Raw}))
71+
return c.set(cpcps.ExistingCertificateConfigKey, existingPEM)
72+
}
73+
74+
// set returns a copy of the Config with the given key (inside the Global
75+
// namespace) set to the given value.
76+
func (c Config) set(key string, value string) (Config, error) {
77+
tree, err := toml.Load(c.toml)
78+
if err != nil {
79+
return Config{}, fmt.Errorf("failed to parse zlint config: %w", err)
80+
}
81+
tree.SetPath([]string{cpcps.GlobalConfigNamespace, key}, value)
82+
tomlString, err := tree.ToTomlString()
83+
if err != nil {
84+
return Config{}, fmt.Errorf("failed to serialize zlint config: %w", err)
85+
}
86+
return Config{toml: tomlString}, nil
87+
}
88+
89+
// build converts the Config into the lint.Configuration that zlint consumes.
90+
func (c Config) build() (lint.Configuration, error) {
91+
return lint.NewConfigFromString(c.toml)
92+
}
93+
94+
// configuredRegistry implements the zlint.Registry interface by embedding a
95+
// normal Registry but replacing the GetConfiguration method with one that
96+
// returns our own config. This allows us to easily supply different config
97+
// objects for each lint pass without having to modify the underlying registry.
98+
type configuredRegistry struct {
99+
lint.Registry
100+
config lint.Configuration
101+
}
102+
103+
// GetConfiguration returns the config associated with this registry. It
104+
// satisfies the zlint.Registry interface.
105+
func (r configuredRegistry) GetConfiguration() lint.Configuration {
106+
return r.config
107+
}
108+
109+
// NewRegistryWithConfig is like NewRegistry, but the returned registry also
110+
// carries the contents of the given Config.
111+
func NewRegistryWithConfig(skipLints []string, config Config) (lint.Registry, error) {
112+
reg, err := NewRegistry(skipLints)
113+
if err != nil {
114+
return nil, err
115+
}
116+
lintConfig, err := config.build()
117+
if err != nil {
118+
return nil, err
119+
}
120+
return configuredRegistry{reg, lintConfig}, nil
121+
}

0 commit comments

Comments
 (0)