From 45fdbfbbaa24f27ecf78375be96538b4651997ed Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Fri, 17 Jul 2026 12:08:10 -0700 Subject: [PATCH 01/18] Factor out GenerateSKID for use across multiple packages --- ca/ca.go | 29 +----------- ca/ca_test.go | 12 ----- cmd/ceremony/cert.go | 25 ++--------- cmd/ceremony/cert_test.go | 95 ++++++++++++++++++++------------------- cmd/ceremony/key.go | 8 ++-- cmd/ceremony/main.go | 30 ++++++------- cmd/ceremony/main_test.go | 8 ++-- core/util.go | 25 +++++++++++ core/util_test.go | 14 ++++++ 9 files changed, 114 insertions(+), 132 deletions(-) diff --git a/ca/ca.go b/ca/ca.go index 5ce5c07d01f..ebad4c709cf 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -3,12 +3,8 @@ package ca import ( "bytes" "context" - "crypto" "crypto/rand" - "crypto/sha256" "crypto/x509" - "crypto/x509/pkix" - "encoding/asn1" "encoding/hex" "errors" "fmt" @@ -251,7 +247,7 @@ func (ca *certificateAuthorityImpl) IssueCertificate(ctx context.Context, req *c return nil, err } - subjectKeyId, err := generateSKID(csr.PublicKey) + subjectKeyId, err := core.GenerateSKID(csr.PublicKey) if err != nil { return nil, fmt.Errorf("computing subject key ID: %w", err) } @@ -492,29 +488,6 @@ func (ca *certificateAuthorityImpl) generateSerialNumber() *big.Int { return serialBigInt } -// generateSKID computes the Subject Key Identifier using one of the methods in -// RFC 7093 Section 2 Additional Methods for Generating Key Identifiers: -// The keyIdentifier [may be] composed of the leftmost 160-bits of the -// SHA-256 hash of the value of the BIT STRING subjectPublicKey -// (excluding the tag, length, and number of unused bits). -func generateSKID(pk crypto.PublicKey) ([]byte, error) { - pkBytes, err := x509.MarshalPKIXPublicKey(pk) - if err != nil { - return nil, err - } - - var pkixPublicKey struct { - Algo pkix.AlgorithmIdentifier - BitString asn1.BitString - } - if _, err := asn1.Unmarshal(pkBytes, &pkixPublicKey); err != nil { - return nil, err - } - - skid := sha256.Sum256(pkixPublicKey.BitString.Bytes) - return skid[0:20:20], nil -} - // verifyTBSCertIsDeterministic verifies that x509.CreateCertificate signing // operation is deterministic and produced identical DER bytes between the given // lint certificate and leaf certificate. If the DER byte equality check fails diff --git a/ca/ca_test.go b/ca/ca_test.go index 81f4737c6f7..0bafcebabdc 100644 --- a/ca/ca_test.go +++ b/ca/ca_test.go @@ -941,18 +941,6 @@ func TestNoteSignError(t *testing.T) { test.AssertMetricWithLabelsEquals(t, metrics.signErrorCount, prometheus.Labels{"type": "HSM"}, 1) } -func TestGenerateSKID(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - test.AssertNotError(t, err, "Error generating key") - - sha256skid, err := generateSKID(key.Public()) - test.AssertNotError(t, err, "Error generating SKID") - test.AssertEquals(t, len(sha256skid), 20) - test.AssertEquals(t, cap(sha256skid), 20) - features.Reset() -} - func TestVerifyTBSCertIsDeterministic(t *testing.T) { t.Parallel() diff --git a/cmd/ceremony/cert.go b/cmd/ceremony/cert.go index 000b4e13bf6..3301030f6f0 100644 --- a/cmd/ceremony/cert.go +++ b/cmd/ceremony/cert.go @@ -2,10 +2,8 @@ package main import ( "crypto" - "crypto/sha256" "crypto/x509" "crypto/x509/pkix" - "encoding/asn1" "errors" "fmt" "io" @@ -13,6 +11,8 @@ import ( "regexp" "slices" "time" + + "github.com/letsencrypt/boulder/core" ) type policyInfoConfig struct { @@ -187,25 +187,8 @@ var stringToKeyUsage = map[string]x509.KeyUsage{ "Cert Sign": x509.KeyUsageCertSign, } -func generateSKID(pk []byte) ([]byte, error) { - var pkixPublicKey struct { - Algo pkix.AlgorithmIdentifier - BitString asn1.BitString - } - if _, err := asn1.Unmarshal(pk, &pkixPublicKey); err != nil { - return nil, err - } - - // RFC 7093 Section 2 Additional Methods for Generating Key Identifiers: The - // keyIdentifier [may be] composed of the leftmost 160-bits of the SHA-256 - // hash of the value of the BIT STRING subjectPublicKey (excluding the tag, - // length, and number of unused bits). - skid := sha256.Sum256(pkixPublicKey.BitString.Bytes) - return skid[0:20:20], nil -} - // makeTemplate generates the certificate template for use in x509.CreateCertificate -func makeTemplate(randReader io.Reader, profile *certProfile, pubKey []byte, tbcs *x509.Certificate, ct certType) (*x509.Certificate, error) { +func makeTemplate(randReader io.Reader, profile *certProfile, pubKey crypto.PublicKey, tbcs *x509.Certificate, ct certType) (*x509.Certificate, error) { // Handle "unrestricted" vs "restricted" subordinate CA profile specifics. if ct == crossCert && tbcs == nil { return nil, fmt.Errorf("toBeCrossSigned cert field was nil, but was required to gather EKUs for the lint cert") @@ -220,7 +203,7 @@ func makeTemplate(randReader io.Reader, profile *certProfile, pubKey []byte, tbc issuingCertificateURL = []string{profile.IssuerURL} } - subjectKeyID, err := generateSKID(pubKey) + subjectKeyID, err := core.GenerateSKID(pubKey) if err != nil { return nil, err } diff --git a/cmd/ceremony/cert_test.go b/cmd/ceremony/cert_test.go index 2fd8f8c11f9..c0cbc96d723 100644 --- a/cmd/ceremony/cert_test.go +++ b/cmd/ceremony/cert_test.go @@ -6,7 +6,6 @@ import ( "crypto/rand" "crypto/x509" "crypto/x509/pkix" - "encoding/hex" "errors" "fmt" "io/fs" @@ -22,16 +21,6 @@ import ( "github.com/letsencrypt/boulder/test" ) -// samplePubkey returns a slice of bytes containing an encoded -// SubjectPublicKeyInfo for an example public key. -func samplePubkey() []byte { - pubKey, err := hex.DecodeString("3059301306072a8648ce3d020106082a8648ce3d03010703420004b06745ef0375c9c54057098f077964e18d3bed0aacd54545b16eab8c539b5768cc1cea93ba56af1e22a7a01c33048c8885ed17c9c55ede70649b707072689f5e") - if err != nil { - panic(err) - } - return pubKey -} - func realRand(_ pkcs11.SessionHandle, length int) ([]byte, error) { r := make([]byte, length) _, err := rand.Read(r) @@ -55,9 +44,13 @@ func TestMakeSubject(t *testing.T) { func TestMakeTemplateEnforcesRootNoEKUs(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() randReader := newRandReader(s) - pubKey := samplePubkey() ctx.GenerateRandomFunc = realRand + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + workingRootProfile := &certProfile{ EKUs: "", KeyUsages: []string{"Digital Signature", "CRL Sign"}, @@ -65,27 +58,27 @@ func TestMakeTemplateEnforcesRootNoEKUs(t *testing.T) { NotBefore: "2026-05-11 00:00:00", NotAfter: "2026-05-12 00:00:00", } - _, err := makeTemplate(randReader, workingRootProfile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, workingRootProfile, key.Public(), nil, rootCert) if err != nil { t.Fatalf("makeTemplate with workingRootProfile: %s", err) } workingRootProfile.EKUs = "none" - _, err = makeTemplate(randReader, workingRootProfile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, workingRootProfile, key.Public(), nil, rootCert) if err != nil { t.Fatalf("makeTemplate with workingRootProfile: %s", err) } brokenRootProfile := *workingRootProfile brokenRootProfile.EKUs = "both" - _, err = makeTemplate(randReader, &brokenRootProfile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, &brokenRootProfile, key.Public(), nil, rootCert) if err == nil { t.Errorf("makeTemplate with brokenRootProfile: got nil error, want error") } brokenRootProfile = *workingRootProfile brokenRootProfile.EKUs = "unintelligible" - _, err = makeTemplate(randReader, &brokenRootProfile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, &brokenRootProfile, key.Public(), nil, rootCert) if err == nil { t.Errorf("makeTemplate with brokenRootProfile: got nil error, want error") } @@ -94,9 +87,13 @@ func TestMakeTemplateEnforcesRootNoEKUs(t *testing.T) { func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() randReader := newRandReader(s) - pubKey := samplePubkey() ctx.GenerateRandomFunc = realRand + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + tbcsCert := &x509.Certificate{ SerialNumber: big.NewInt(666), Subject: pkix.Name{ @@ -117,7 +114,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { NotAfter: "2026-05-12 00:00:00", } - template, err := makeTemplate(randReader, crossCertProfile, pubKey, tbcsCert, crossCert) + template, err := makeTemplate(randReader, crossCertProfile, key.Public(), tbcsCert, crossCert) if err != nil { t.Fatalf("makeTemplate with crossCertProfile: %s", err) } @@ -128,7 +125,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { } crossCertProfile.EKUs = "server" - _, err = makeTemplate(randReader, crossCertProfile, pubKey, tbcsCert, crossCert) + _, err = makeTemplate(randReader, crossCertProfile, key.Public(), tbcsCert, crossCert) if err != nil { t.Fatalf("makeTemplate with crossCertProfile: %s", err) } @@ -139,7 +136,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { // This will error because the tbcsCert has [serverAuth], but "both" means [serverAuth, clientAuth] on the cross sign crossCertProfile.EKUs = "both" - _, err = makeTemplate(randReader, crossCertProfile, pubKey, tbcsCert, crossCert) + _, err = makeTemplate(randReader, crossCertProfile, key.Public(), tbcsCert, crossCert) if err == nil { t.Fatalf("makeTemplate with \"both\" and to-be-cross-signed certificate that has \"serverAuth\": got nil error, want error") } @@ -148,7 +145,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { liberalTBCS := *tbcsCert liberalTBCS.ExtKeyUsage = nil crossCertProfile.EKUs = "both" - _, err = makeTemplate(randReader, crossCertProfile, pubKey, &liberalTBCS, crossCert) + _, err = makeTemplate(randReader, crossCertProfile, key.Public(), &liberalTBCS, crossCert) if err != nil { t.Errorf("makeTemplate with \"both\" and liberal to-be-cross-signed certificate: %s", err) } @@ -157,7 +154,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { crossCertProfile.EKUs = "both" crossCertProfile.NotBefore = "2027-05-11 00:00:00" crossCertProfile.NotAfter = "2027-05-12 00:00:00" - _, err = makeTemplate(randReader, crossCertProfile, pubKey, &liberalTBCS, crossCert) + _, err = makeTemplate(randReader, crossCertProfile, key.Public(), &liberalTBCS, crossCert) if err == nil { t.Fatalf("makeTemplate with \"both\" and late notBefore: go nil error, want error") } @@ -169,9 +166,13 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { func TestMakeTemplateIntermediateEKUs(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() randReader := newRandReader(s) - pubKey := samplePubkey() ctx.GenerateRandomFunc = realRand + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + intermediateProfile := &certProfile{ EKUs: "", KeyUsages: []string{"Digital Signature", "CRL Sign"}, @@ -180,7 +181,7 @@ func TestMakeTemplateIntermediateEKUs(t *testing.T) { NotAfter: "2026-05-12 00:00:00", } - template, err := makeTemplate(randReader, intermediateProfile, pubKey, nil, intermediateCert) + template, err := makeTemplate(randReader, intermediateProfile, key.Public(), nil, intermediateCert) if err != nil { t.Fatalf("makeTemplate with intermediateProfile: %s", err) } @@ -191,7 +192,7 @@ func TestMakeTemplateIntermediateEKUs(t *testing.T) { } intermediateProfile.EKUs = "server" - template, err = makeTemplate(randReader, intermediateProfile, pubKey, nil, intermediateCert) + template, err = makeTemplate(randReader, intermediateProfile, key.Public(), nil, intermediateCert) if err != nil { t.Fatalf("makeTemplate with intermediateProfile and EKUs: \"server\": %s", err) } @@ -202,7 +203,7 @@ func TestMakeTemplateIntermediateEKUs(t *testing.T) { } intermediateProfile.EKUs = "both" - template, err = makeTemplate(randReader, intermediateProfile, pubKey, nil, intermediateCert) + template, err = makeTemplate(randReader, intermediateProfile, key.Public(), nil, intermediateCert) if err != nil { t.Fatalf("makeTemplate with intermediateProfile and EKUs: \"both\": %s", err) } @@ -213,7 +214,7 @@ func TestMakeTemplateIntermediateEKUs(t *testing.T) { } intermediateProfile.EKUs = "unintelligible" - _, err = makeTemplate(randReader, intermediateProfile, pubKey, nil, intermediateCert) + _, err = makeTemplate(randReader, intermediateProfile, key.Public(), nil, intermediateCert) if err == nil { t.Fatalf("makeTemplate with intermediateProfile and EKUs: \"unintelligible\": got nil error, want error") } @@ -223,42 +224,46 @@ func TestMakeTemplateRoot(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() profile := &certProfile{} randReader := newRandReader(s) - pubKey := samplePubkey() ctx.GenerateRandomFunc = realRand + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + profile.NotBefore = "1234" - _, err := makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid not before") profile.NotBefore = "2018-05-18 11:31:00" profile.NotAfter = "1234" - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid not after") profile.NotAfter = "2018-05-18 11:31:00" profile.SignatureAlgorithm = "nope" - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid signature algorithm") profile.SignatureAlgorithm = "SHA256WithRSA" ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) { return nil, errors.New("bad") } - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail when GenerateRandom failed") ctx.GenerateRandomFunc = realRand - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with empty key usages") profile.KeyUsages = []string{"asd"} - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid key usages") profile.KeyUsages = []string{"Digital Signature", "CRL Sign"} profile.Policies = []policyInfoConfig{{}} - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid (empty) policy OID") profile.Policies = []policyInfoConfig{{OID: "1.2.3"}, {OID: "1.2.3.4"}} @@ -267,7 +272,7 @@ func TestMakeTemplateRoot(t *testing.T) { profile.Country = "country" profile.CRLURL = "crl" profile.IssuerURL = "issuer" - cert, err := makeTemplate(randReader, profile, pubKey, nil, rootCert) + cert, err := makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected") test.AssertEquals(t, cert.Subject.CommonName, profile.CommonName) test.AssertEquals(t, len(cert.Subject.Organization), 1) @@ -282,7 +287,7 @@ func TestMakeTemplateRoot(t *testing.T) { test.AssertEquals(t, len(cert.Policies), 2) test.AssertEquals(t, len(cert.ExtKeyUsage), 0) - cert, err = makeTemplate(randReader, profile, pubKey, nil, intermediateCert) + cert, err = makeTemplate(randReader, profile, key.Public(), nil, intermediateCert) test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected") test.Assert(t, cert.MaxPathLenZero, "MaxPathLenZero not set in intermediate template") test.AssertEquals(t, len(cert.ExtKeyUsage), 1) @@ -293,7 +298,12 @@ func TestMakeTemplateRestrictedCrossCertificate(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() ctx.GenerateRandomFunc = realRand randReader := newRandReader(s) - pubKey := samplePubkey() + + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + profile := &certProfile{ SignatureAlgorithm: "SHA256WithRSA", CommonName: "common name", @@ -318,7 +328,7 @@ func TestMakeTemplateRestrictedCrossCertificate(t *testing.T) { BasicConstraintsValid: true, } - cert, err := makeTemplate(randReader, profile, pubKey, &tbcsCert, crossCert) + cert, err := makeTemplate(randReader, profile, key.Public(), &tbcsCert, crossCert) test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected") test.Assert(t, !cert.MaxPathLenZero, "MaxPathLenZero was set in cross-sign") test.AssertEquals(t, len(cert.ExtKeyUsage), 1) @@ -575,10 +585,3 @@ func TestLoadCert(t *testing.T) { _, err = loadCert("../../test/hierarchy/int-e1.key.pem") test.AssertError(t, err, "should have failed when trying to parse a private key") } - -func TestGenerateSKID(t *testing.T) { - sha256skid, err := generateSKID(samplePubkey()) - test.AssertNotError(t, err, "Error generating SKID") - test.AssertEquals(t, len(sha256skid), 20) - test.AssertEquals(t, cap(sha256skid), 20) -} diff --git a/cmd/ceremony/key.go b/cmd/ceremony/key.go index 2315a2081a3..5db5987d8a2 100644 --- a/cmd/ceremony/key.go +++ b/cmd/ceremony/key.go @@ -36,12 +36,10 @@ type generateArgs struct { } // keyInfo is a struct used to pass around information about the public key -// associated with the generated private key. der contains the DER encoding -// of the SubjectPublicKeyInfo structure for the public key. id contains the -// HSM key pair object ID. +// associated with the generated private key. id contains the HSM key pair +// object ID. type keyInfo struct { key crypto.PublicKey - der []byte id []byte } @@ -81,5 +79,5 @@ func generateKey(session *pkcs11helpers.Session, label string, outputPath string } log.Printf("Public key written to %q\n", outputPath) - return &keyInfo{key: pubKey, der: der, id: keyID}, nil + return &keyInfo{key: pubKey, id: keyID}, nil } diff --git a/cmd/ceremony/main.go b/cmd/ceremony/main.go index 604a06a3536..d7f87b80515 100644 --- a/cmd/ceremony/main.go +++ b/cmd/ceremony/main.go @@ -534,30 +534,28 @@ func signAndWriteCert(tbs, issuer *x509.Certificate, lintCert lintCert, subjectP return cert, nil } -// loadPubKey loads a PEM public key specified by filename. It returns a -// crypto.PublicKey, the PEM bytes of the public key, and an error. If an error -// exists, no public key or bytes are returned. The public key is checked by the -// GoodKey package. -func loadPubKey(filename string) (crypto.PublicKey, []byte, error) { +// loadPubKey loads a PEM public key specified by filename. The public key is +// checked by the GoodKey package. +func loadPubKey(filename string) (crypto.PublicKey, error) { keyPEM, err := os.ReadFile(filename) if err != nil { - return nil, nil, err + return nil, err } log.Printf("Loaded public key from %s\n", filename) block, _ := pem.Decode(keyPEM) if block == nil { - return nil, nil, fmt.Errorf("no data in cert PEM file %q", filename) + return nil, fmt.Errorf("no data in cert PEM file %q", filename) } key, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { - return nil, nil, err + return nil, err } err = kp.GoodKey(context.Background(), key) if err != nil { - return nil, nil, err + return nil, err } - return key, block.Bytes, nil + return key, nil } func rootCeremony(configBytes []byte) error { @@ -584,7 +582,7 @@ func rootCeremony(configBytes []byte) error { if err != nil { return fmt.Errorf("failed to retrieve signer: %s", err) } - template, err := makeTemplate(newRandReader(session), &config.CertProfile, keyInfo.der, nil, rootCert) + template, err := makeTemplate(newRandReader(session), &config.CertProfile, keyInfo.key, nil, rootCert) if err != nil { return fmt.Errorf("failed to create certificate profile: %s", err) } @@ -616,7 +614,7 @@ func intermediateCeremony(configBytes []byte) error { if err != nil { return fmt.Errorf("failed to validate config: %s", err) } - pub, pubBytes, err := loadPubKey(config.Inputs.PublicKeyPath) + pub, err := loadPubKey(config.Inputs.PublicKeyPath) if err != nil { return err } @@ -628,7 +626,7 @@ func intermediateCeremony(configBytes []byte) error { if err != nil { return err } - template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, nil, intermediateCert) + template, err := makeTemplate(randReader, &config.CertProfile, pub, nil, intermediateCert) if err != nil { return fmt.Errorf("failed to create certificate profile: %s", err) } @@ -668,7 +666,7 @@ func crossCertCeremony(configBytes []byte) error { if err != nil { return fmt.Errorf("failed to validate config: %s", err) } - pub, pubBytes, err := loadPubKey(config.Inputs.PublicKeyPath) + pub, err := loadPubKey(config.Inputs.PublicKeyPath) if err != nil { return err } @@ -684,7 +682,7 @@ func crossCertCeremony(configBytes []byte) error { if err != nil { return err } - template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, toBeCrossSigned, crossCert) + template, err := makeTemplate(randReader, &config.CertProfile, pub, toBeCrossSigned, crossCert) if err != nil { return fmt.Errorf("failed to create certificate profile: %s", err) } @@ -770,7 +768,7 @@ func csrCeremony(configBytes []byte) error { return fmt.Errorf("failed to validate config: %s", err) } - pub, _, err := loadPubKey(config.Inputs.PublicKeyPath) + pub, err := loadPubKey(config.Inputs.PublicKeyPath) if err != nil { return err } diff --git a/cmd/ceremony/main_test.go b/cmd/ceremony/main_test.go index 899cb2909cc..697d97ec172 100644 --- a/cmd/ceremony/main_test.go +++ b/cmd/ceremony/main_test.go @@ -24,21 +24,21 @@ func TestLoadPubKey(t *testing.T) { tmp := t.TempDir() key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - _, _, err := loadPubKey(path.Join(tmp, "does", "not", "exist")) + _, err := loadPubKey(path.Join(tmp, "does", "not", "exist")) test.AssertError(t, err, "should fail on non-existent file") test.AssertErrorIs(t, err, fs.ErrNotExist) - _, _, err = loadPubKey("../../test/hierarchy/README.md") + _, err = loadPubKey("../../test/hierarchy/README.md") test.AssertError(t, err, "should fail on non-PEM file") priv, _ := x509.MarshalPKCS8PrivateKey(key) _ = os.WriteFile(path.Join(tmp, "priv.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv}), 0644) - _, _, err = loadPubKey(path.Join(tmp, "priv.pem")) + _, err = loadPubKey(path.Join(tmp, "priv.pem")) test.AssertError(t, err, "should fail on non-pubkey PEM") pub, _ := x509.MarshalPKIXPublicKey(key.Public()) _ = os.WriteFile(path.Join(tmp, "pub.pem"), pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub}), 0644) - _, _, err = loadPubKey(path.Join(tmp, "pub.pem")) + _, err = loadPubKey(path.Join(tmp, "pub.pem")) test.AssertNotError(t, err, "should not have errored") } diff --git a/core/util.go b/core/util.go index e1228fbe6b5..e41e2e8ae8d 100644 --- a/core/util.go +++ b/core/util.go @@ -8,6 +8,8 @@ import ( "crypto/rsa" "crypto/sha256" "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" "encoding/base64" "encoding/hex" "encoding/pem" @@ -154,6 +156,29 @@ func PublicKeysEqual(a, b crypto.PublicKey) (bool, error) { } } +// GenerateSKID computes the Subject Key Identifier using one of the methods in +// RFC 7093 Section 2 Additional Methods for Generating Key Identifiers: +// The keyIdentifier [may be] composed of the leftmost 160-bits of the +// SHA-256 hash of the value of the BIT STRING subjectPublicKey +// (excluding the tag, length, and number of unused bits). +func GenerateSKID(pub crypto.PublicKey) ([]byte, error) { + pkBytes, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return nil, err + } + + var pkixPublicKey struct { + Algo pkix.AlgorithmIdentifier + BitString asn1.BitString + } + if _, err := asn1.Unmarshal(pkBytes, &pkixPublicKey); err != nil { + return nil, err + } + + skid := sha256.Sum256(pkixPublicKey.BitString.Bytes) + return skid[0:20:20], nil +} + // SerialToString converts a certificate serial number (big.Int) to a String // consistently. func SerialToString(serial *big.Int) string { diff --git a/core/util_test.go b/core/util_test.go index a64a073d73e..6de8c3aec6e 100644 --- a/core/util_test.go +++ b/core/util_test.go @@ -2,6 +2,9 @@ package core import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "encoding/json" "errors" "fmt" @@ -117,6 +120,17 @@ func TestKeyDigestEquals(t *testing.T) { test.Assert(t, !KeyDigestEquals(struct{}{}, struct{}{}), "Unknown key types should not match anything") } +func TestGenerateSKID(t *testing.T) { + t.Parallel() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Error generating key") + + sha256skid, err := GenerateSKID(key.Public()) + test.AssertNotError(t, err, "Error generating SKID") + test.AssertEquals(t, len(sha256skid), 20) + test.AssertEquals(t, cap(sha256skid), 20) +} + func TestIsAnyNilOrZero(t *testing.T) { test.Assert(t, IsAnyNilOrZero(nil), "Nil seen as non-zero") From 1895147bd3f462e5087e8060496e3e79b8bd9930 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Fri, 17 Jul 2026 17:46:05 -0700 Subject: [PATCH 02/18] Add infrastructure to configure lints with issuer and pre-existing cert --- cmd/ceremony/main.go | 39 +++++--- cmd/ceremony/main_test.go | 4 +- go.mod | 2 +- issuance/cert.go | 22 +++-- issuance/cert_test.go | 102 ++++++++++++++------- issuance/issuer_test.go | 3 + linter/config.go | 121 +++++++++++++++++++++++++ linter/config_test.go | 183 ++++++++++++++++++++++++++++++++++++++ linter/linter.go | 35 +++++++- linter/linter_test.go | 2 +- 10 files changed, 454 insertions(+), 59 deletions(-) create mode 100644 linter/config.go create mode 100644 linter/config_test.go diff --git a/cmd/ceremony/main.go b/cmd/ceremony/main.go index d7f87b80515..0ee69aa9fb7 100644 --- a/cmd/ceremony/main.go +++ b/cmd/ceremony/main.go @@ -46,8 +46,15 @@ type lintCert *x509.Certificate // template certificate signed by a given issuer and returns a *lintCert or an // error. The lint certificate is linted prior to being returned. The public key // from the just issued lint certificate is checked by the GoodKey package. -func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey crypto.PublicKey, signer crypto.Signer, skipLints []string) (lintCert, error) { - bytes, err := linter.Check(tbs, subjectPubKey, issuer, signer, skipLints) +// When cross-signing, existing is the pre-existing certificate of the CA being +// cross-signed, allowing the CP/CPS profile lints to check correspondence with +// it; it is nil otherwise. +func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey crypto.PublicKey, signer crypto.Signer, existing *x509.Certificate, skipLints []string) (lintCert, error) { + lintConfig, err := linter.Config{}.WithExisting(existing) + if err != nil { + return nil, fmt.Errorf("unable to create lint config: %w", err) + } + bytes, err := linter.Check(tbs, subjectPubKey, issuer, signer, lintConfig, skipLints) if err != nil { return nil, fmt.Errorf("certificate failed pre-issuance lint: %w", err) } @@ -66,8 +73,10 @@ func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey // postIssuanceLinting performs post-issuance linting on the raw bytes of a // given certificate with the same set of lints as // issueLintCertAndPerformLinting. The public key is also checked by the GoodKey -// package. -func postIssuanceLinting(fc *x509.Certificate, skipLints []string) error { +// package. The issuer and existing certificates, when non-nil, are supplied to +// the CP/CPS profile lints so that they can perform their correspondence +// checks. +func postIssuanceLinting(fc, issuer, existing *x509.Certificate, skipLints []string) error { if fc == nil { return fmt.Errorf("certificate was not provided") } @@ -77,7 +86,15 @@ func postIssuanceLinting(fc *x509.Certificate, skipLints []string) error { // lint. This should be treated as ZLint rejecting the certificate return fmt.Errorf("unable to parse certificate: %s", err) } - registry, err := linter.NewRegistry(skipLints) + lintConfig, err := linter.Config{}.WithIssuer(issuer) + if err != nil { + return fmt.Errorf("unable to create lint config: %s", err) + } + lintConfig, err = lintConfig.WithExisting(existing) + if err != nil { + return fmt.Errorf("unable to create lint config: %s", err) + } + registry, err := linter.NewRegistryWithConfig(skipLints, lintConfig) if err != nil { return fmt.Errorf("unable to create zlint registry: %s", err) } @@ -586,7 +603,7 @@ func rootCeremony(configBytes []byte) error { if err != nil { return fmt.Errorf("failed to create certificate profile: %s", err) } - lintCert, err := issueLintCertAndPerformLinting(template, template, keyInfo.key, signer, config.SkipLints) + lintCert, err := issueLintCertAndPerformLinting(template, template, keyInfo.key, signer, nil, config.SkipLints) if err != nil { return err } @@ -594,7 +611,7 @@ func rootCeremony(configBytes []byte) error { if err != nil { return err } - err = postIssuanceLinting(finalCert, config.SkipLints) + err = postIssuanceLinting(finalCert, nil, nil, config.SkipLints) if err != nil { return err } @@ -631,7 +648,7 @@ func intermediateCeremony(configBytes []byte) error { return fmt.Errorf("failed to create certificate profile: %s", err) } template.AuthorityKeyId = issuer.SubjectKeyId - lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, config.SkipLints) + lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, nil, config.SkipLints) if err != nil { return err } @@ -646,7 +663,7 @@ func intermediateCeremony(configBytes []byte) error { if !bytes.Equal(lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) { return fmt.Errorf("mismatch between lintCert and finalCert RawTBSCertificate DER bytes: \"%x\" != \"%x\"", lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) } - err = postIssuanceLinting(finalCert, config.SkipLints) + err = postIssuanceLinting(finalCert, issuer, nil, config.SkipLints) if err != nil { return err } @@ -687,7 +704,7 @@ func crossCertCeremony(configBytes []byte) error { return fmt.Errorf("failed to create certificate profile: %s", err) } template.AuthorityKeyId = issuer.SubjectKeyId - lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, config.SkipLints) + lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, toBeCrossSigned, config.SkipLints) if err != nil { return err } @@ -748,7 +765,7 @@ func crossCertCeremony(configBytes []byte) error { if !bytes.Equal(lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) { return fmt.Errorf("mismatch between lintCert and finalCert RawTBSCertificate DER bytes: \"%x\" != \"%x\"", lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) } - err = postIssuanceLinting(finalCert, config.SkipLints) + err = postIssuanceLinting(finalCert, issuer, toBeCrossSigned, config.SkipLints) if err != nil { return err } diff --git a/cmd/ceremony/main_test.go b/cmd/ceremony/main_test.go index 697d97ec172..c6cd04364fa 100644 --- a/cmd/ceremony/main_test.go +++ b/cmd/ceremony/main_test.go @@ -1292,7 +1292,7 @@ func TestSignAndWriteNoLintCert(t *testing.T) { func TestPostIssuanceLinting(t *testing.T) { clk := clock.New() - err := postIssuanceLinting(nil, nil) + err := postIssuanceLinting(nil, nil, nil, nil) test.AssertError(t, err, "should have failed because no certificate was provided") testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -1306,6 +1306,6 @@ func TestPostIssuanceLinting(t *testing.T) { test.AssertNotError(t, err, "unable to create certificate") parsedCert, err := x509.ParseCertificate(certDer) test.AssertNotError(t, err, "unable to parse DER bytes") - err = postIssuanceLinting(parsedCert, nil) + err = postIssuanceLinting(parsedCert, nil, nil, nil) test.AssertNotError(t, err, "should not have errored") } diff --git a/go.mod b/go.mod index e5a34ef20ed..16a7c80f9e0 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/miekg/dns v1.1.62 github.com/miekg/pkcs11 v1.1.2 github.com/nxadm/tail v1.4.11 + github.com/pelletier/go-toml v1.9.5 github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_model v0.6.1 github.com/redis/go-redis/extra/redisotel/v9 v9.5.3 @@ -76,7 +77,6 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pelletier/go-toml v1.9.5 // indirect github.com/poy/onpar v1.1.2 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect diff --git a/issuance/cert.go b/issuance/cert.go index f7e23387689..56b42c150f8 100644 --- a/issuance/cert.go +++ b/issuance/cert.go @@ -77,7 +77,14 @@ type Profile struct { maxCertificateSize int + // lints is the registry of lints to run against certificates issued under + // this profile. It carries no lint configuration of its own: at issuance + // time it is combined with a configuration derived from lintConfig. lints lint.Registry + // lintConfig is the in-memory contents of this profile's zlint config + // file. At issuance time it is augmented with the issuing Issuer's + // certificate via WithIssuer. + lintConfig linter.Config } // NewProfile converts the profile config into a usable profile. @@ -102,11 +109,9 @@ func NewProfile(profileConfig ProfileConfig) (*Profile, error) { lints, err := linter.NewRegistry(profileConfig.IgnoredLints) cmd.FailOnError(err, "Failed to create zlint registry") - if profileConfig.LintConfig != "" { - lintconfig, err := lint.NewConfigFromFile(profileConfig.LintConfig) - cmd.FailOnError(err, "Failed to load zlint config file") - lints.SetConfiguration(lintconfig) - } + + lintConfig, err := linter.LoadConfigFile(profileConfig.LintConfig) + cmd.FailOnError(err, "Failed to load zlint config file") sp := &Profile{ omitCommonName: profileConfig.OmitCommonName, @@ -118,6 +123,7 @@ func NewProfile(profileConfig ProfileConfig) (*Profile, error) { maxValidity: profileConfig.MaxValidityPeriod.Duration, maxCertificateSize: profileConfig.MaxCertificateSize, lints: lints, + lintConfig: lintConfig, } return sp, nil @@ -386,7 +392,11 @@ func (i *Issuer) Prepare(prof *Profile, req *IssuanceRequest) ([]byte, *issuance // check that the tbsCertificate is properly formed by signing it // with a throwaway key and then linting it using zlint - lintCertBytes, err := i.Linter.Check(template, req.PublicKey.PublicKey, prof.lints) + lintConfig, err := prof.lintConfig.WithIssuer(i.Cert.Certificate) + if err != nil { + return nil, nil, fmt.Errorf("building lint config: %w", err) + } + lintCertBytes, err := i.Linter.Check(template, req.PublicKey.PublicKey, prof.lints, lintConfig) if err != nil { return nil, nil, fmt.Errorf("tbsCertificate linting failed: %w", err) } diff --git a/issuance/cert_test.go b/issuance/cert_test.go index aa184d00621..51534e92c2b 100644 --- a/issuance/cert_test.go +++ b/issuance/cert_test.go @@ -21,12 +21,16 @@ import ( "github.com/jmhodges/clock" "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" "github.com/letsencrypt/boulder/ctpolicy/loglist" "github.com/letsencrypt/boulder/linter" "github.com/letsencrypt/boulder/test" ) var ( + // goodSKID is a fake subject key ID for tests which only exercise request + // validation. Tests which also run lints must use core.GenerateSKID because + // our custom lints verify that the SKID matches the actual public key. goodSKID = []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9} ) @@ -269,7 +273,7 @@ func TestRequestValid(t *testing.T) { SubjectKeyId: goodSKID, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour), - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, IncludeCTPoison: true, }, }, @@ -286,7 +290,7 @@ func TestRequestValid(t *testing.T) { SubjectKeyId: goodSKID, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour), - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, sctList: []ct.SignedCertificateTimestamp{}, }, }, @@ -357,10 +361,12 @@ func TestIssue(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := tc.generateFunc() test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") lintCertBytes, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, IPAddresses: []net.IP{net.ParseIP("128.101.101.101"), net.ParseIP("3fff:aaa:a:c0ff:ee:a:bad:deed")}, NotBefore: fc.Now(), @@ -385,7 +391,7 @@ func TestIssue(t *testing.T) { // addresses back to 4 bytes. Adding .To4() both allows this test to // succeed, and covers this requirement. test.AssertDeepEquals(t, cert.IPAddresses, []net.IP{net.ParseIP("128.101.101.101").To4(), net.ParseIP("3fff:aaa:a:c0ff:ee:a:bad:deed")}) - test.AssertByteEquals(t, cert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}) + test.AssertByteEquals(t, cert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}) test.AssertDeepEquals(t, cert.PublicKey, pk.Public()) test.AssertEquals(t, len(cert.Extensions), 10) // Constraints, KU, EKU, SKID, AKID, AIA, CRLDP, SAN, Policies, Poison test.AssertEquals(t, cert.KeyUsage, tc.ku) @@ -440,10 +446,14 @@ func TestIssueDNSNamesOnly(t *testing.T) { if err != nil { t.Fatalf("ecdsa.GenerateKey: %s", err) } + skid, err := core.GenerateSKID(pk.Public()) + if err != nil { + t.Fatalf("core.GenerateSKID: %s", err) + } _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -479,10 +489,14 @@ func TestIssueIPAddressesOnly(t *testing.T) { if err != nil { t.Fatalf("ecdsa.GenerateKey: %s", err) } + skid, err := core.GenerateSKID(pk.Public()) + if err != nil { + t.Fatalf("core.GenerateSKID: %s", err) + } _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, IPAddresses: []net.IP{net.ParseIP("128.101.101.101"), net.ParseIP("3fff:aaa:a:c0ff:ee:a:bad:deed")}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -521,11 +535,15 @@ func TestIssueWithCRLDP(t *testing.T) { if err != nil { t.Fatalf("ecdsa.GenerateKey: %s", err) } + skid, err := core.GenerateSKID(pk.Public()) + if err != nil { + t.Fatalf("core.GenerateSKID: %s", err) + } profile := defaultProfile() _, issuanceToken, err := signer.Prepare(profile, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -543,7 +561,7 @@ func TestIssueWithCRLDP(t *testing.T) { t.Fatalf("x509.ParseCertificate: %s", err) } // Because CRL shard is calculated deterministically from serial, we know which shard will be chosen. - expectedCRLDP := []string{"http://crls.example.net/919.crl"} + expectedCRLDP := []string{"http://crls.example.net/838.crl"} if !reflect.DeepEqual(cert.CRLDistributionPoints, expectedCRLDP) { t.Errorf("CRLDP=%+v, want %+v", cert.CRLDistributionPoints, expectedCRLDP) } @@ -561,10 +579,12 @@ func TestIssueCommonName(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") ir := &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com", "www.example.com"}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -697,10 +717,12 @@ func TestIssueOmissions(t *testing.T) { pk, err := rsa.GenerateKey(rand.Reader, 2048) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := signer.Prepare(prof, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, CommonName: "example.com", IncludeCTPoison: true, @@ -726,10 +748,12 @@ func TestIssueCTPoison(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, IncludeCTPoison: true, NotBefore: fc.Now(), @@ -742,7 +766,7 @@ func TestIssueCTPoison(t *testing.T) { test.AssertNotError(t, err, "failed to parse certificate") err = cert.CheckSignatureFrom(issuerCert.Certificate) test.AssertNotError(t, err, "signature validation failed") - test.AssertByteEquals(t, cert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}) + test.AssertByteEquals(t, cert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}) test.AssertDeepEquals(t, cert.PublicKey, pk.Public()) test.AssertEquals(t, len(cert.Extensions), 10) // Constraints, KU, EKU, SKID, AKID, AIA, CRLDP, SAN, Policies, Poison test.AssertDeepEquals(t, cert.Extensions[9], ctPoisonExt) @@ -774,10 +798,12 @@ func TestIssueSCTList(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := signer.Prepare(enforceSCTsProfile, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -814,7 +840,7 @@ func TestIssueSCTList(t *testing.T) { err = finalCert.CheckSignatureFrom(issuerCert.Certificate) test.AssertNotError(t, err, "signature validation failed") - test.AssertByteEquals(t, finalCert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}) + test.AssertByteEquals(t, finalCert.SerialNumber.Bytes(), []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}) test.AssertDeepEquals(t, finalCert.PublicKey, pk.Public()) test.AssertEquals(t, len(finalCert.Extensions), 10) // Constraints, KU, EKU, SKID, AKID, AIA, CRLDP, SAN, Policies, Poison test.AssertDeepEquals(t, finalCert.Extensions[9], pkix.Extension{ @@ -842,10 +868,12 @@ func TestIssueBadLint(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, _, err = signer.Prepare(noSkipLintsProfile, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example-com"}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -871,10 +899,12 @@ func TestIssuanceToken(t *testing.T) { pk, err := rsa.GenerateKey(rand.Reader, 2048) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -890,8 +920,8 @@ func TestIssuanceToken(t *testing.T) { _, issuanceToken, err = signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -918,10 +948,12 @@ func TestInvalidProfile(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, _, err = signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -932,8 +964,8 @@ func TestInvalidProfile(t *testing.T) { _, _, err = signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), NotAfter: fc.Now().Add(time.Hour - time.Second), @@ -967,10 +999,12 @@ func TestMismatchedProfiles(t *testing.T) { pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := issuer1.Prepare(cnProfile, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, - Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, + SubjectKeyId: skid, + Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18}, CommonName: "example.com", DNSNames: []string{"example.com"}, NotBefore: fc.Now(), diff --git a/issuance/issuer_test.go b/issuance/issuer_test.go index aa9911c4e57..021b869ef46 100644 --- a/issuance/issuer_test.go +++ b/issuance/issuer_test.go @@ -24,6 +24,9 @@ import ( func defaultProfileConfig() ProfileConfig { return ProfileConfig{ + // Our CP/CPS profile forbids the clientAuth EKU, and the + // e_precertificate_matches_cps_profile lint enforces that. + OmitClientAuth: true, MaxValidityPeriod: config.Duration{Duration: time.Hour}, MaxValidityBackdate: config.Duration{Duration: time.Hour}, IgnoredLints: []string{ diff --git a/linter/config.go b/linter/config.go new file mode 100644 index 00000000000..4e49e99c7a4 --- /dev/null +++ b/linter/config.go @@ -0,0 +1,121 @@ +package linter + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "os" + + "github.com/pelletier/go-toml" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/cpcps" +) + +// Config is a validated, in-memory zlint lint configuration. The zero value +// is an empty configuration. +type Config struct { + // The zlint package only accepts configuration as TOML (and re-parses it on + // every lint pass anyway), so we store the config in zlint's format. + toml string +} + +// LoadConfigFile reads and validates a zlint TOML config file. An empty path +// yields an empty Config. It is an error for the file to set the shared +// configuration keys read by the CP/CPS profile lints: those are derived from +// certificates via WithIssuer and WithExisting instead. +func LoadConfigFile(path string) (Config, error) { + if path == "" { + return Config{}, nil + } + contents, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("failed to read zlint config file: %w", err) + } + tree, err := toml.LoadBytes(contents) + if err != nil { + return Config{}, fmt.Errorf("failed to parse zlint config file %q: %w", path, err) + } + for _, key := range []string{cpcps.IssuerCertificateConfigKey, cpcps.ExistingCertificateConfigKey} { + if tree.HasPath([]string{cpcps.GlobalConfigNamespace, key}) { + return Config{}, fmt.Errorf("zlint config file %q must not set %s.%s: it is derived from the issuer certificate", path, cpcps.GlobalConfigNamespace, key) + } + } + return Config{toml: string(contents)}, nil +} + +// WithIssuer returns a copy of the Config with a stanza holding the PEM of the +// issuer's certificate. This is necessary for the CP/CPS profile lints, which +// check that certain fields of the certificate being linted match the issuer. A +// nil issuer, or one with no raw DER bytes (i.e. a to-be-signed template rather +// than a real certificate, as in a self-signed root ceremony), returns the +// Config unchanged. +func (c Config) WithIssuer(issuer *x509.Certificate) (Config, error) { + if issuer == nil || len(issuer.Raw) == 0 { + return c, nil + } + issuerPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuer.Raw})) + return c.set(cpcps.IssuerCertificateConfigKey, issuerPEM) +} + +// WithExisting returns a copy of the Config with a stanza holding the PEM of +// the pre-existing certificate which is being cross-signed. This is necessary +// for the CP/CPS Cross-Certified Subordinate CA Certificate lint. It is only +// used by the ceremony tool. A nil existing certificate, or one with no raw DER +// bytes, returns the Config unchanged. +func (c Config) WithExisting(existing *x509.Certificate) (Config, error) { + if existing == nil || len(existing.Raw) == 0 { + return c, nil + } + existingPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: existing.Raw})) + return c.set(cpcps.ExistingCertificateConfigKey, existingPEM) +} + +// set returns a copy of the Config with the given key (inside the Global +// namespace) set to the given value. +func (c Config) set(key string, value string) (Config, error) { + tree, err := toml.Load(c.toml) + if err != nil { + return Config{}, fmt.Errorf("failed to parse zlint config: %w", err) + } + tree.SetPath([]string{cpcps.GlobalConfigNamespace, key}, value) + tomlString, err := tree.ToTomlString() + if err != nil { + return Config{}, fmt.Errorf("failed to serialize zlint config: %w", err) + } + return Config{toml: tomlString}, nil +} + +// build converts the Config into the lint.Configuration that zlint consumes. +func (c Config) build() (lint.Configuration, error) { + return lint.NewConfigFromString(c.toml) +} + +// configuredRegistry implements the zlint.Registry interface by embedding a +// normal Registry but replacing the GetConfiguration method with one that +// returns our own config. This allows us to easily supply different config +// objects for each lint pass without having to modify the underlying registry. +type configuredRegistry struct { + lint.Registry + config lint.Configuration +} + +// GetConfiguration returns the config associated with this registry. It +// satisfies the zlint.Registry interface. +func (r configuredRegistry) GetConfiguration() lint.Configuration { + return r.config +} + +// NewRegistryWithConfig is like NewRegistry, but the returned registry also +// carries the contents of the given Config. +func NewRegistryWithConfig(skipLints []string, config Config) (lint.Registry, error) { + reg, err := NewRegistry(skipLints) + if err != nil { + return nil, err + } + lintConfig, err := config.build() + if err != nil { + return nil, err + } + return configuredRegistry{reg, lintConfig}, nil +} diff --git a/linter/config_test.go b/linter/config_test.go new file mode 100644 index 00000000000..ca235ff9931 --- /dev/null +++ b/linter/config_test.go @@ -0,0 +1,183 @@ +package linter + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path" + "strings" + "testing" + "time" + + "github.com/letsencrypt/boulder/linter/lints/cpcps" +) + +// testCert generates a minimal self-signed certificate with the given CN. +func testCert(t *testing.T, cn string) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating test key: %s", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parsing test certificate: %s", err) + } + return cert +} + +func TestLoadConfigFile(t *testing.T) { + t.Parallel() + + cfg, err := LoadConfigFile("") + if err != nil { + t.Errorf("got error %q for empty path, want nil", err) + } + if cfg.toml != "" { + t.Errorf("got non-empty config %q for empty path", cfg.toml) + } + + dir := t.TempDir() + + good := path.Join(dir, "good.toml") + err = os.WriteFile(good, []byte("[w_subject_common_name_included]\nsomething = true\n"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + _, err = LoadConfigFile(good) + if err != nil { + t.Errorf("got error %q for valid config file, want nil", err) + } + + garbage := path.Join(dir, "garbage.toml") + err = os.WriteFile(garbage, []byte("this is not toml"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + _, err = LoadConfigFile(garbage) + if err == nil { + t.Error("got nil error for invalid config file, want error") + } + + // Config files may not set the shared keys read by the CP/CPS profile + // lints: their configuration is derived from the issuer certificate. + // Other keys in the shared stanza are permitted. + reserved := path.Join(dir, "reserved.toml") + err = os.WriteFile(reserved, []byte("[Global]\nissuer_certificate = \"bogus\"\n"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + _, err = LoadConfigFile(reserved) + if err == nil || !strings.Contains(err.Error(), "must not set") { + t.Errorf("got %v for config file with reserved key, want rejection", err) + } + + otherGlobal := path.Join(dir, "other-global.toml") + err = os.WriteFile(otherGlobal, []byte("[Global]\nsomething_else = true\n"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + _, err = LoadConfigFile(otherGlobal) + if err != nil { + t.Errorf("got error %q for config file with unrelated shared-stanza key, want nil", err) + } + + _, err = LoadConfigFile(path.Join(dir, "does-not-exist.toml")) + if err == nil { + t.Error("got nil error for nonexistent config file, want error") + } +} + +// TestConfigDerivation exercises the whole layering path: a config file +// loaded from disk, augmented with an issuer and an existing certificate, +// built into a single zlint Configuration in which all three are visible. +func TestConfigDerivation(t *testing.T) { + t.Parallel() + + filePath := path.Join(t.TempDir(), "zlint.toml") + err := os.WriteFile(filePath, []byte("[w_subject_common_name_included]\nsomething = true\n"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + config, err := LoadConfigFile(filePath) + if err != nil { + t.Fatalf("loading config file: %s", err) + } + + issuer := testCert(t, "issuer") + config, err = config.WithIssuer(issuer) + if err != nil { + t.Fatalf("adding issuer to config: %s", err) + } + + existing := testCert(t, "existing") + config, err = config.WithExisting(existing) + if err != nil { + t.Fatalf("adding existing certificate to config: %s", err) + } + + merged, err := config.build() + if err != nil { + t.Fatalf("building lint configuration: %s", err) + } + + var gotCross struct { + IssuerCertificatePEM string `toml:"issuer_certificate"` + ExistingCertificatePEM string `toml:"existing_certificate"` + } + err = merged.Configure(&gotCross, cpcps.GlobalConfigNamespace) + if err != nil { + t.Fatalf("deserializing issuer lint configuration: %s", err) + } + + issuerBlock, _ := pem.Decode([]byte(gotCross.IssuerCertificatePEM)) + if issuerBlock == nil { + t.Fatal("issuer_certificate did not round-trip as PEM") + } + if !issuer.Equal(mustParse(t, issuerBlock.Bytes)) { + t.Error("issuer_certificate does not match the configured issuer") + } + + existingBlock, _ := pem.Decode([]byte(gotCross.ExistingCertificatePEM)) + if existingBlock == nil { + t.Fatal("existing_certificate did not round-trip as PEM") + } + if !existing.Equal(mustParse(t, existingBlock.Bytes)) { + t.Error("existing_certificate does not match the configured existing certificate") + } + + var gotFile struct { + Something bool `toml:"something"` + } + err = merged.Configure(&gotFile, "w_subject_common_name_included") + if err != nil { + t.Fatalf("deserializing file lint configuration: %s", err) + } + if !gotFile.Something { + t.Error("config-file section is not visible in the merged configuration") + } +} + +func mustParse(t *testing.T, der []byte) *x509.Certificate { + t.Helper() + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parsing certificate: %s", err) + } + return cert +} diff --git a/linter/linter.go b/linter/linter.go index e52aa6a3e81..6395b740cf3 100644 --- a/linter/linter.go +++ b/linter/linter.go @@ -16,7 +16,6 @@ import ( _ "github.com/letsencrypt/boulder/linter/lints/cabf_br" _ "github.com/letsencrypt/boulder/linter/lints/chrome" - _ "github.com/letsencrypt/boulder/linter/lints/cpcps" _ "github.com/letsencrypt/boulder/linter/lints/rfc" ) @@ -30,18 +29,23 @@ var ErrLinting = fmt.Errorf("failed lint(s)") // primary public interface of this package, but it can be inefficient; creating // a new signer and a new lint registry are expensive operations which // performance-sensitive clients may want to cache via linter.New(). -func Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, realIssuer *x509.Certificate, realSigner crypto.Signer, skipLints []string) ([]byte, error) { +func Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, realIssuer *x509.Certificate, realSigner crypto.Signer, config Config, skipLints []string) ([]byte, error) { linter, err := New(realIssuer, realSigner) if err != nil { return nil, err } + config, err = config.WithIssuer(realIssuer) + if err != nil { + return nil, err + } + reg, err := NewRegistry(skipLints) if err != nil { return nil, err } - lintCertBytes, err := linter.Check(tbs, subjectPubKey, reg) + lintCertBytes, err := linter.Check(tbs, subjectPubKey, reg, config) if err != nil { return nil, err } @@ -98,14 +102,37 @@ func New(realIssuer *x509.Certificate, realSigner crypto.Signer) (*Linter, error // replaced with the linter's pubkey so that it appears self-signed. It returns // an error if any lint fails. On success it also returns the DER bytes of the // linting certificate. -func (l *Linter) Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, reg lint.Registry) ([]byte, error) { +func (l *Linter) Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, reg lint.Registry, config Config) ([]byte, error) { + if reg == nil { + reg = lint.GlobalRegistry() + } + + lintConfig, err := config.build() + if err != nil { + return nil, err + } + + reg = configuredRegistry{reg, lintConfig} + lintPubKey := subjectPubKey selfSigned, err := core.PublicKeysEqual(subjectPubKey, l.realPubKey) if err != nil { return nil, err } if selfSigned { + // If the cert being linted is going to be self-signed, replace the lint + // cert's public key and subjectKeyId extension with ones built from the + // fake lint signing key, so that everything lines up as it should. lintPubKey = l.signer.Public() + if len(tbs.SubjectKeyId) != 0 { + lintSKID, err := core.GenerateSKID(lintPubKey) + if err != nil { + return nil, err + } + tbsCopy := *tbs + tbsCopy.SubjectKeyId = lintSKID + tbs = &tbsCopy + } } lintCertBytes, cert, err := makeLintCert(tbs, lintPubKey, l.issuer, l.signer) diff --git a/linter/linter_test.go b/linter/linter_test.go index 3b35578e907..b6cfebd917d 100644 --- a/linter/linter_test.go +++ b/linter/linter_test.go @@ -97,7 +97,7 @@ func TestMakeIssuer(t *testing.T) { ee := &x509.Certificate{} - lintCertBytes, err := linter.Check(ee, eeKey.Public(), nil) + lintCertBytes, err := linter.Check(ee, eeKey.Public(), nil, Config{}) if err != nil { t.Fatal(err) } From a753a99f2fe92c52fe7cf16df6c13e0663499bc8 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Fri, 17 Jul 2026 17:46:22 -0700 Subject: [PATCH 03/18] Add CP/CPS profile lints --- linter/lints/common.go | 1 + linter/lints/cpcps/helpers.go | 186 ++++++ linter/lints/cpcps/helpers_test.go | 548 ++++++++++++++++++ ...ss_certified_subordinate_ca_certificate.go | 345 +++++++++++ ...rtified_subordinate_ca_certificate_test.go | 182 ++++++ linter/lints/cpcps/lint_precertificate.go | 101 ++++ .../lints/cpcps/lint_precertificate_test.go | 136 +++++ ...t_validity_period_greater_than_25_years.go | 49 -- .../lints/cpcps/lint_root_ca_certificate.go | 244 ++++++++ .../cpcps/lint_root_ca_certificate_test.go | 183 ++++++ ...rt_validity_period_greater_than_8_years.go | 49 -- ...ber_cert_validity_greater_than_100_days.go | 49 -- .../lint_subscriber_server_certificate.go | 428 ++++++++++++++ ...lint_subscriber_server_certificate_test.go | 292 ++++++++++ .../lint_tls_subordinate_ca_certificate.go | 351 +++++++++++ ...int_tls_subordinate_ca_certificate_test.go | 229 ++++++++ 16 files changed, 3226 insertions(+), 147 deletions(-) create mode 100644 linter/lints/cpcps/helpers.go create mode 100644 linter/lints/cpcps/helpers_test.go create mode 100644 linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go create mode 100644 linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go create mode 100644 linter/lints/cpcps/lint_precertificate.go create mode 100644 linter/lints/cpcps/lint_precertificate_test.go delete mode 100644 linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go create mode 100644 linter/lints/cpcps/lint_root_ca_certificate.go create mode 100644 linter/lints/cpcps/lint_root_ca_certificate_test.go delete mode 100644 linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go delete mode 100644 linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go create mode 100644 linter/lints/cpcps/lint_subscriber_server_certificate.go create mode 100644 linter/lints/cpcps/lint_subscriber_server_certificate_test.go create mode 100644 linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go create mode 100644 linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go diff --git a/linter/lints/common.go b/linter/lints/common.go index b2f071759ab..7395b885c44 100644 --- a/linter/lints/common.go +++ b/linter/lints/common.go @@ -27,6 +27,7 @@ const ( var ( CPSV20Date = time.Date(2017, time.April, 13, 0, 0, 0, 0, time.UTC) CPSV33Date = time.Date(2021, time.June, 8, 0, 0, 0, 0, time.UTC) + CPSV62Date = time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC) // TKTK: Speculative! MozillaPolicy281Date = time.Date(2023, time.February, 15, 0, 0, 0, 0, time.UTC) ) diff --git a/linter/lints/cpcps/helpers.go b/linter/lints/cpcps/helpers.go new file mode 100644 index 00000000000..66da02ec16c --- /dev/null +++ b/linter/lints/cpcps/helpers.go @@ -0,0 +1,186 @@ +package cpcps + +// This file contains constants and parsing utilities shared by the lints which +// enforce the certificate profiles found in Section 7.1 of our CP/CPS. Only +// mechanical helpers (extracting bytes, computing hashes, constructing +// results) live here: every actual profile check is written out inline in the +// lint which enforces it, so that each lint can be read top-to-bottom against +// the text of the CP/CPS, and so that the profiles can diverge independently. + +import ( + "encoding/pem" + "fmt" + + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/pkix" + "github.com/zmap/zlint/v3/lint" + "golang.org/x/crypto/cryptobyte" + cryptobyte_asn1 "golang.org/x/crypto/cryptobyte/asn1" +) + +var ( + // The AlgorithmIdentifier encodings specified by Section 7.1.3.2 of the + // Baseline Requirements, which Section 7.1.3.2 of our CP/CPS incorporates + // by reference. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + brSignatureAlgorithmIdentifiers = map[string]bool{ + // sha256WithRSAEncryption + "300d06092a864886f70d01010b0500": true, + // sha384WithRSAEncryption + "300d06092a864886f70d01010c0500": true, + // sha512WithRSAEncryption + "300d06092a864886f70d01010d0500": true, + // ecdsa-with-SHA256 + "300a06082a8648ce3d040302": true, + // ecdsa-with-SHA384 + "300a06082a8648ce3d040303": true, + // ecdsa-with-SHA512 + "300a06082a8648ce3d040304": true, + } + + // The SubjectPublicKeyInfo AlgorithmIdentifier encodings specified by + // Section 7.1.3.1 of the Baseline Requirements, which Section 7.1.3.1 of + // our CP/CPS incorporates by reference. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + spkiAlgorithmRSA = "300d06092a864886f70d0101010500" + spkiAlgorithmP256 = "301306072a8648ce3d020106082a8648ce3d030107" + spkiAlgorithmP384 = "301006072a8648ce3d020106052b81040022" + spkiAlgorithmP521 = "301006072a8648ce3d020106052b81040023" + + // Extension OIDs used by the profile lints. + authorityInformationAccessOID = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 1, 1} + authorityKeyIdentifierOID = asn1.ObjectIdentifier{2, 5, 29, 35} + basicConstraintsOID = asn1.ObjectIdentifier{2, 5, 29, 19} + certificatePoliciesOID = asn1.ObjectIdentifier{2, 5, 29, 32} + crlDistributionPointsOID = asn1.ObjectIdentifier{2, 5, 29, 31} + extKeyUsageOID = asn1.ObjectIdentifier{2, 5, 29, 37} + keyUsageOID = asn1.ObjectIdentifier{2, 5, 29, 15} + subjectAltNameOID = asn1.ObjectIdentifier{2, 5, 29, 17} + subjectKeyIdentifierOID = asn1.ObjectIdentifier{2, 5, 29, 14} + sctListOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2} + + // The Baseline Requirements Domain Validated Reserved Policy Identifier. + domainValidatedOID = asn1.ObjectIdentifier{2, 23, 140, 1, 2, 1} + + // Subject attribute type OIDs. + commonNameOID = asn1.ObjectIdentifier{2, 5, 4, 3} +) + +// Keys within the shared configuration stanza. These must match the toml +// tags on IssuingCAConfig's fields, and are exported so that the linter +// package can emit configuration using these keys. +const ( + // GlobalConfigNamespace is the name of the TOML stanza from which + // IssuingCAConfig is deserialized. It must match the namespace of zlint's + // lint.Global higher-scoped configuration, which IssuingCAConfig embeds. + GlobalConfigNamespace = "Global" + // IssuerCertificateConfigKey configures the Issuing CA's certificate. + IssuerCertificateConfigKey = "issuer_certificate" + // ExistingCertificateConfigKey configures the pre-existing certificate of + // a CA being cross-signed. + ExistingCertificateConfigKey = "existing_certificate" +) + +// globalNamespace aliases zlint's lint.Global so that IssuingCAConfig can +// embed it under an unexported field name. The promoted (unexported) +// namespace method is what routes deserialization of IssuingCAConfig to the +// shared [Global] stanza, via zlint's "higher-scoped configuration" +// mechanism; the unexported field name makes zlint's reflection-based config +// resolver skip the embedded field itself, which it could not deserialize. +type globalNamespace = lint.Global //nolint:unused // Used in SharedConfig. + +// SharedConfig is the lint configuration shared by every CP/CPS profile +// lint. Rather than each lint carrying an identical stanza of its own, all of +// them declare a pointer to this struct, which zlint fills from the single +// shared [Global] stanza of the lint configuration. +type SharedConfig struct { + globalNamespace //nolint:unused // Used by zlint, not by us. + // IssuerCertificatePEM must hold the PEM encoding of the Issuing CA's + // certificate, so that the profile rows requiring byte-for-byte + // correspondence with the Issuing CA can be enforced. If it is not + // configured, the CP/CPS profile lints fail. + IssuerCertificatePEM string `toml:"issuer_certificate" comment:"The PEM encoding of the Issuing CA's certificate."` + // ExistingCertificatePEM must hold the PEM encoding of the existing CA + // Certificate upon which a cross-certificate confers a second issuance + // path. It is read only by the cross-certified subordinate CA profile + // lint, and only the ceremony tool ever configures it, because only the + // ceremony tool issues cross-certificates. + ExistingCertificatePEM string `toml:"existing_certificate" comment:"The PEM encoding of the existing CA Certificate being cross-signed."` +} + +// issuerPEM returns the configured Issuing CA certificate PEM, or the empty +// string if the receiver was never configured. +func (c *SharedConfig) issuerPEM() string { + if c == nil { + return "" + } + return c.IssuerCertificatePEM +} + +// existingPEM returns the configured existing CA certificate PEM, or the +// empty string if the receiver was never configured. +func (c *SharedConfig) existingPEM() string { + if c == nil { + return "" + } + return c.ExistingCertificatePEM +} + +// errResult is a convenience constructor for a failing lint result. +func errResult(details string) *lint.LintResult { + return &lint.LintResult{Status: lint.Error, Details: details} +} + +// fatalResult is a convenience constructor for a fatal lint result, used when +// the lint's own configuration is unusable. +func fatalResult(details string) *lint.LintResult { + return &lint.LintResult{Status: lint.Fatal, Details: details} +} + +// getOuterSignatureAlgorithm returns the DER bytes (including tag and length) +// of the signatureAlgorithm field of the outer Certificate sequence. +func getOuterSignatureAlgorithm(der []byte) ([]byte, error) { + input := cryptobyte.String(der) + var certificate cryptobyte.String + if !input.ReadASN1(&certificate, cryptobyte_asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to parse certificate") + } + if !certificate.SkipASN1(cryptobyte_asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to parse tbsCertificate") + } + var signatureAlgorithm cryptobyte.String + if !certificate.ReadASN1Element(&signatureAlgorithm, cryptobyte_asn1.SEQUENCE) { + return nil, fmt.Errorf("failed to parse signatureAlgorithm") + } + return signatureAlgorithm, nil +} + +// getExtension returns the extension with the given OID, or nil if absent. +func getExtension(c *x509.Certificate, oid asn1.ObjectIdentifier) *pkix.Extension { + for _, ext := range c.Extensions { + if ext.Id.Equal(oid) { + return &pkix.Extension{Id: ext.Id, Critical: ext.Critical, Value: ext.Value} + } + } + return nil +} + +// parseConfiguredCertificate parses a PEM certificate provided via lint +// configuration. It returns a nil certificate and nil error if the +// configuration string is empty; lints which require the certificate must +// treat that as a failure. +func parseConfiguredCertificate(pemBytes string) (*x509.Certificate, error) { + if pemBytes == "" { + return nil, nil + } + block, _ := pem.Decode([]byte(pemBytes)) + if block == nil || block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("failed to decode configured PEM certificate") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse configured certificate: %w", err) + } + return cert, nil +} diff --git a/linter/lints/cpcps/helpers_test.go b/linter/lints/cpcps/helpers_test.go new file mode 100644 index 00000000000..4af2cd2c3f6 --- /dev/null +++ b/linter/lints/cpcps/helpers_test.go @@ -0,0 +1,548 @@ +package cpcps + +// Helpers for building test certificates for the CP/CPS profile lints. The +// certificates are constructed with crypto/x509 and then re-parsed with +// zcrypto, mirroring how the linter package produces the certificates that +// these lints run against in production. + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/hex" + "encoding/pem" + "fmt" + "math/big" + "strings" + "testing" + "time" + + zx509 "github.com/zmap/zcrypto/x509" + zpkix "github.com/zmap/zcrypto/x509/pkix" + "github.com/zmap/zlint/v3/lint" +) + +var ( + testDomainValidatedOID = x509.OID{} + testSCTListOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 2} + testCTPoisonOID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 3} +) + +func init() { + var err error + testDomainValidatedOID, err = x509.OIDFromInts([]uint64{2, 23, 140, 1, 2, 1}) + if err != nil { + panic(err) + } +} + +// testKey generates an ECDSA key on the given curve. +func testKey(t *testing.T, curve elliptic.Curve) *ecdsa.PrivateKey { + t.Helper() + key, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + t.Fatalf("generating test key: %s", err) + } + return key +} + +// testSerial returns a positive integer occupying exactly nBytes bytes. +func testSerial(t *testing.T, nBytes int) *big.Int { + t.Helper() + bytes := make([]byte, nBytes) + _, err := rand.Read(bytes) + if err != nil { + t.Fatalf("generating test serial: %s", err) + } + // Force the first byte into [0x40, 0x7f] so the value occupies exactly + // nBytes bytes and its DER encoding needs no leading zero byte. + bytes[0] = bytes[0]&0x3f | 0x40 + return new(big.Int).SetBytes(bytes) +} + +// testRFC7093SKID computes a key identifier per RFC 7093 Section 2(1): the +// leftmost 160 bits of the SHA-256 hash of the subjectPublicKey BIT STRING +// contents. +func testRFC7093SKID(t *testing.T, pub crypto.PublicKey) []byte { + t.Helper() + spkiDER, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatalf("marshalling public key: %s", err) + } + var spki struct { + Algorithm pkix.AlgorithmIdentifier + PublicKey asn1.BitString + } + _, err = asn1.Unmarshal(spkiDER, &spki) + if err != nil { + t.Fatalf("unmarshalling public key: %s", err) + } + hash := sha256.Sum256(spki.PublicKey.Bytes) + return hash[:20] +} + +// testParseZCert parses DER into a zcrypto certificate. +func testParseZCert(t *testing.T, der []byte) *zx509.Certificate { + t.Helper() + cert, err := zx509.ParseCertificate(der) + if err != nil { + t.Fatalf("parsing certificate with zcrypto: %s", err) + } + return cert +} + +// testRootTemplate returns a template matching the Root CA Certificate +// Profile from CP/CPS Section 7.1. +func testRootTemplate(t *testing.T, pub crypto.PublicKey) *x509.Certificate { + t.Helper() + notBefore := time.Date(2025, time.October, 1, 0, 0, 0, 0, time.UTC) + return &x509.Certificate{ + SerialNumber: testSerial(t, 16), + Subject: pkix.Name{ + Country: []string{"US"}, + Organization: []string{"ISRG"}, + CommonName: "ISRG Root X99", + }, + NotBefore: notBefore, + // 9132 days, inclusive of the final second. + NotAfter: notBefore.AddDate(0, 0, 9132).Add(-time.Second), + BasicConstraintsValid: true, + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + SubjectKeyId: testRFC7093SKID(t, pub), + } +} + +// testIntermediateTemplate returns a template approximating the TLS Subordinate CA +// Certificate Profile from CP/CPS Section 7.1. +func testIntermediateTemplate(t *testing.T, pub crypto.PublicKey) *x509.Certificate { + t.Helper() + notBefore := time.Date(2025, time.November, 1, 0, 0, 0, 0, time.UTC) + return &x509.Certificate{ + SerialNumber: testSerial(t, 16), + Subject: pkix.Name{ + Country: []string{"US"}, + Organization: []string{"Let's Encrypt"}, + CommonName: "E99", + }, + NotBefore: notBefore, + NotAfter: notBefore.AddDate(5, 0, 0).Add(-time.Second), + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + MaxPathLenZero: true, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + Policies: []x509.OID{testDomainValidatedOID}, + IssuingCertificateURL: []string{"http://x99.i.lencr.org/"}, + CRLDistributionPoints: []string{"http://x99.c.lencr.org/1.crl"}, + SubjectKeyId: testRFC7093SKID(t, pub), + } +} + +// testSCT returns the RFC 6962 serialization of a fake (unverifiable) v1 SCT +// from the log with the given ID. +func testSCT(logID [32]byte) []byte { + var sct []byte + sct = append(sct, 0) // sct_version v1(0) + sct = append(sct, logID[:]...) + sct = append(sct, make([]byte, 8)...) // timestamp + sct = append(sct, 0, 0) // no extensions + sct = append(sct, 4, 3) // sha256, ecdsa + sct = append(sct, 0, 4, 1, 2, 3, 4) // 4-byte placeholder signature + return sct +} + +// testSCTListExtension returns a SignedCertificateTimestampList extension +// containing one fake SCT per given log ID. +func testSCTListExtension(t *testing.T, logIDs ...[32]byte) pkix.Extension { + t.Helper() + var list []byte + for _, logID := range logIDs { + sct := testSCT(logID) + list = append(list, byte(len(sct)>>8), byte(len(sct))) + list = append(list, sct...) + } + full := append([]byte{byte(len(list) >> 8), byte(len(list))}, list...) + value, err := asn1.Marshal(full) + if err != nil { + t.Fatalf("marshalling SCT list: %s", err) + } + return pkix.Extension{Id: testSCTListOID, Value: value} +} + +// testLeafTemplate returns a template matching the Subscriber (Server) +// Certificate Profile from CP/CPS Section 7.1, containing SCTs from two +// distinct logs. +func testLeafTemplate(t *testing.T) *x509.Certificate { + t.Helper() + notBefore := time.Date(2025, time.December, 1, 0, 0, 0, 0, time.UTC) + return &x509.Certificate{ + SerialNumber: testSerial(t, 18), + Subject: pkix.Name{ + CommonName: "example.com", + }, + DNSNames: []string{"example.com", "www.example.com"}, + NotBefore: notBefore, + NotAfter: notBefore.AddDate(0, 0, 100).Add(-time.Second), + BasicConstraintsValid: true, + IsCA: false, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + Policies: []x509.OID{testDomainValidatedOID}, + IssuingCertificateURL: []string{"http://e99.i.lencr.org/"}, + CRLDistributionPoints: []string{"http://e99.c.lencr.org/1.crl"}, + ExtraExtensions: []pkix.Extension{ + testSCTListExtension(t, [32]byte{1}, [32]byte{2}), + }, + } +} + +// testPEM returns the PEM encoding of the given certificate DER. +func testPEM(t *testing.T, der []byte) string { + t.Helper() + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +func TestSubscriberProfileIssuerCorrespondence(t *testing.T) { + t.Parallel() + + intKey := testKey(t, elliptic.P384()) + intTmpl := testIntermediateTemplate(t, intKey.Public()) + intDER, err := x509.CreateCertificate(rand.Reader, intTmpl, intTmpl, intKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test issuer: %s", err) + } + + otherKey := testKey(t, elliptic.P384()) + otherTmpl := testIntermediateTemplate(t, otherKey.Public()) + otherTmpl.Subject.CommonName = "E98" + otherDER, err := x509.CreateCertificate(rand.Reader, otherTmpl, otherTmpl, otherKey.Public(), otherKey) + if err != nil { + t.Fatalf("creating test issuer: %s", err) + } + + leafKey := testKey(t, elliptic.P256()) + leafDER, err := x509.CreateCertificate(rand.Reader, testLeafTemplate(t), intTmpl, leafKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + leaf := testParseZCert(t, leafDER) + + testCases := []struct { + name string + issuerPEM string + want lint.LintStatus + wantSubStr string + }{ + { + name: "unconfigured", + issuerPEM: "", + want: lint.Fatal, + wantSubStr: "lint has not been configured with the Issuing CA's certificate", + }, + { + name: "matching_issuer", + issuerPEM: testPEM(t, intDER), + want: lint.Pass, + }, + { + name: "mismatched_issuer", + issuerPEM: testPEM(t, otherDER), + want: lint.Error, + wantSubStr: "issuer is not byte-for-byte identical to the subject of the configured Issuing CA", + }, + { + name: "garbage_config", + issuerPEM: "not a pem block", + want: lint.Fatal, + wantSubStr: "failed to decode configured PEM certificate", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + l := &subscriberServerCertificateMatchesCPSProfile{Config: &SharedConfig{IssuerCertificatePEM: tc.issuerPEM}} + res := l.Execute(leaf) + if res.Status != tc.want { + t.Errorf("got status %s (%q), want %s", res.Status, res.Details, tc.want) + } + if !strings.Contains(res.Details, tc.wantSubStr) { + t.Errorf("got details %q, want substring %q", res.Details, tc.wantSubStr) + } + }) + } +} + +func TestSubscriberProfileConfigurationViaTOML(t *testing.T) { + t.Parallel() + + intKey := testKey(t, elliptic.P384()) + intTmpl := testIntermediateTemplate(t, intKey.Public()) + intDER, err := x509.CreateCertificate(rand.Reader, intTmpl, intTmpl, intKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test issuer: %s", err) + } + + leafKey := testKey(t, elliptic.P256()) + leafDER, err := x509.CreateCertificate(rand.Reader, testLeafTemplate(t), intTmpl, leafKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + leaf := testParseZCert(t, leafDER) + + toml := fmt.Sprintf("[Global]\nissuer_certificate = '''\n%s'''\n", testPEM(t, intDER)) + cfg, err := lint.NewConfigFromString(toml) + if err != nil { + t.Fatalf("parsing TOML config: %s", err) + } + + l := NewSubscriberServerCertificateMatchesCPSProfile() + err = cfg.MaybeConfigure(l, "e_subscriber_server_certificate_matches_cps_profile") + if err != nil { + t.Fatalf("configuring lint: %s", err) + } + + configured := l.(*subscriberServerCertificateMatchesCPSProfile) + if configured.Config.issuerPEM() == "" { + t.Fatal("TOML configuration did not populate the shared config") + } + + res := l.Execute(leaf) + if res.Status != lint.Pass { + t.Errorf("got status %s (%q), want pass", res.Status, res.Details) + } +} + +func TestCrossCertifiedProfileExistingCertCorrespondence(t *testing.T) { + t.Parallel() + + // The issuing root and the existing root being cross-signed. + rootKey := testKey(t, elliptic.P384()) + rootTmpl := testRootTemplate(t, rootKey.Public()) + rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, rootKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test issuer: %s", err) + } + + existingKey := testKey(t, elliptic.P384()) + existingTmpl := testRootTemplate(t, existingKey.Public()) + existingTmpl.Subject.CommonName = "ISRG Root X100" + existingDER, err := x509.CreateCertificate(rand.Reader, existingTmpl, existingTmpl, existingKey.Public(), existingKey) + if err != nil { + t.Fatalf("creating existing CA certificate: %s", err) + } + + testCases := []struct { + name string + mod func(t *testing.T, tmpl *x509.Certificate) + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "wrong_subject", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.CommonName = "ISRG Root X101" + }, + want: lint.Error, + wantSubStr: "subject is not byte-for-byte identical to the subject of the configured existing CA Certificate", + }, + { + name: "wrong_skid", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.SubjectKeyId = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + }, + want: lint.Error, + wantSubStr: "subjectKeyIdentifier is not byte-for-byte identical to that of the configured existing CA Certificate", + }, + { + name: "wrong_pathlen", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // The existing root has no pathLenConstraint, so including one + // in the cross-certificate is a mismatch. + tmpl.MaxPathLen = 0 + tmpl.MaxPathLenZero = true + }, + want: lint.Error, + wantSubStr: "basicConstraints pathLenConstraint is not identical to that of the configured existing CA Certificate", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + tmpl := testCrossCertTemplate(t, existingKey.Public()) + tmpl.Subject = existingTmpl.Subject + if tc.mod != nil { + tc.mod(t, tmpl) + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, rootTmpl, existingKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + cert := testParseZCert(t, der) + + l := &crossCertifiedSubordinateCACertificateMatchesCPSProfile{Config: &SharedConfig{ + IssuerCertificatePEM: testPEM(t, rootDER), + ExistingCertificatePEM: testPEM(t, existingDER), + }} + if !l.CheckApplies(cert) { + t.Fatal("lint does not apply to test certificate") + } + + res := l.Execute(cert) + if res.Status != tc.want { + t.Errorf("got status %s (%q), want %s", res.Status, res.Details, tc.want) + } + if !strings.Contains(res.Details, tc.wantSubStr) { + t.Errorf("got details %q, want substring %q", res.Details, tc.wantSubStr) + } + }) + } +} + +// testSelfSignedZCert builds a self-signed certificate from the root template +// using the given key and signature algorithm (zero for the default choice), +// and parses it with zcrypto. +func testSelfSignedZCert(t *testing.T, key crypto.Signer, sigAlg x509.SignatureAlgorithm) *zx509.Certificate { + t.Helper() + tmpl := testRootTemplate(t, key.Public()) + tmpl.SignatureAlgorithm = sigAlg + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + return testParseZCert(t, der) +} + +func TestErrResult(t *testing.T) { + t.Parallel() + + res := errResult("some details") + if res.Status != lint.Error { + t.Errorf("got status %s, want %s", res.Status, lint.Error) + } + if res.Details != "some details" { + t.Errorf("got details %q, want %q", res.Details, "some details") + } +} + +func TestFatalResult(t *testing.T) { + t.Parallel() + + res := fatalResult("some details") + if res.Status != lint.Fatal { + t.Errorf("got status %s, want %s", res.Status, lint.Fatal) + } + if res.Details != "some details" { + t.Errorf("got details %q, want %q", res.Details, "some details") + } +} + +func TestGetOuterSignatureAlgorithm(t *testing.T) { + t.Parallel() + + cert := testSelfSignedZCert(t, testKey(t, elliptic.P384()), 0) + got, err := getOuterSignatureAlgorithm(cert.Raw) + if err != nil { + t.Fatalf("getOuterSignatureAlgorithm: %s", err) + } + // A P-384 key signs with ecdsa-with-SHA384 by default. + want := "300a06082a8648ce3d040303" + if hex.EncodeToString(got) != want { + t.Errorf("got %s, want %s", hex.EncodeToString(got), want) + } + + _, err = getOuterSignatureAlgorithm([]byte{}) + if err == nil || !strings.Contains(err.Error(), "failed to parse certificate") { + t.Errorf("got %v, want failure to parse certificate", err) + } + + // A SEQUENCE whose first element is an INTEGER, not a tbsCertificate. + _, err = getOuterSignatureAlgorithm([]byte{0x30, 0x03, 0x02, 0x01, 0x01}) + if err == nil || !strings.Contains(err.Error(), "failed to parse tbsCertificate") { + t.Errorf("got %v, want failure to parse tbsCertificate", err) + } + + // A SEQUENCE containing only a tbsCertificate, with no signatureAlgorithm. + _, err = getOuterSignatureAlgorithm([]byte{0x30, 0x02, 0x30, 0x00}) + if err == nil || !strings.Contains(err.Error(), "failed to parse signatureAlgorithm") { + t.Errorf("got %v, want failure to parse signatureAlgorithm", err) + } +} + +func TestGetExtension(t *testing.T) { + t.Parallel() + + cert := &zx509.Certificate{ + Extensions: []zpkix.Extension{ + {Id: keyUsageOID, Critical: true, Value: []byte{0x01}}, + {Id: basicConstraintsOID, Critical: false, Value: []byte{0x02}}, + }, + } + + got := getExtension(cert, keyUsageOID) + if got == nil { + t.Fatal("got nil, want keyUsage extension") + } + if !got.Id.Equal(keyUsageOID) || !got.Critical || !bytes.Equal(got.Value, []byte{0x01}) { + t.Errorf("got %+v, want the keyUsage extension with its criticality and value preserved", got) + } + + if getExtension(cert, subjectAltNameOID) != nil { + t.Error("got an extension for an absent OID, want nil") + } +} + +func TestParseConfiguredCertificate(t *testing.T) { + t.Parallel() + + cert, err := parseConfiguredCertificate("") + if err != nil { + t.Errorf("got error %q for empty configuration, want nil", err) + } + if cert != nil { + t.Error("got a certificate for empty configuration, want nil") + } + + _, err = parseConfiguredCertificate("not a pem block") + if err == nil || !strings.Contains(err.Error(), "failed to decode configured PEM certificate") { + t.Errorf("got %v, want failure to decode PEM", err) + } + + realCert := testSelfSignedZCert(t, testKey(t, elliptic.P384()), 0) + + wrongType := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: realCert.Raw})) + _, err = parseConfiguredCertificate(wrongType) + if err == nil || !strings.Contains(err.Error(), "failed to decode configured PEM certificate") { + t.Errorf("got %v, want failure to decode PEM", err) + } + + garbageDER := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte{1, 2, 3}})) + _, err = parseConfiguredCertificate(garbageDER) + if err == nil || !strings.Contains(err.Error(), "failed to parse configured certificate") { + t.Errorf("got %v, want failure to parse certificate", err) + } + + cert, err = parseConfiguredCertificate(testPEM(t, realCert.Raw)) + if err != nil { + t.Fatalf("parseConfiguredCertificate: %s", err) + } + if cert == nil || !bytes.Equal(cert.Raw, realCert.Raw) { + t.Error("parsed certificate does not match the configured certificate") + } +} diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go new file mode 100644 index 00000000000..189acaca466 --- /dev/null +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -0,0 +1,345 @@ +package cpcps + +import ( + "bytes" + "crypto/elliptic" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/zmap/zcrypto/encoding/asn1" + zrsa "github.com/zmap/zcrypto/rsa" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type crossCertifiedSubordinateCACertificateMatchesCPSProfile struct { + // Config is filled from the shared [Global] stanza of the lint + // configuration; see IssuingCAConfig. + Config *SharedConfig +} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_cross_certified_subordinate_ca_certificate_matches_cps_profile", + Description: "Let's Encrypt Cross-Certified Subordinate CA Certificates are issued in accordance with the CP/CPS Profile", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV62Date, + }, + Lint: NewCrossCertifiedSubordinateCACertificateMatchesCPSProfile, + }) +} + +func NewCrossCertifiedSubordinateCACertificateMatchesCPSProfile() lint.CertificateLintInterface { + return &crossCertifiedSubordinateCACertificateMatchesCPSProfile{} +} + +// Configure implements the lint.Configurable interface. +func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Configure() any { + return l +} + +func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) CheckApplies(c *x509.Certificate) bool { + return util.IsSubCA(c) && isCrossCertified(c) +} + +// Execute checks the given certificate against the Cross-Certified Subordinate +// CA Certificate Profile, row by row. +// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1014-L1039 +func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { + // Several rows of the profile require byte-for-byte correspondence with + // the Issuing CA's certificate or with the existing CA Certificate being + // cross-signed, so this lint must be configured with both. + issuer, err := parseConfiguredCertificate(l.Config.issuerPEM()) + if err != nil { + return fatalResult(err.Error()) + } + if issuer == nil { + return fatalResult("lint has not been configured with the Issuing CA's certificate (issuer_certificate)") + } + existing, err := parseConfiguredCertificate(l.Config.existingPEM()) + if err != nil { + return fatalResult(err.Error()) + } + if existing == nil { + return fatalResult("lint has not been configured with the existing CA Certificate being cross-signed (existing_certificate)") + } + + // version is "X.509 version 3" (Section 7.1.1). Note that unlike + // crypto/x509, zcrypto's Version field is one-indexed: it holds 3 (not the + // raw encoded value 2) for a v3 certificate. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1019 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1101-L1103 + if c.Version != 3 { + return errResult("version is not v3") + } + + // serialNumber is "Approximately 128 bits, including at least 64 bits of + // output from a CSPRNG". We can't test randomness here, but a 128-bit + // serial occupies exactly 16 bytes. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1020 + if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { + return errResult("serialNumber is not a positive integer") + } + if (c.SerialNumber.BitLen()+7)/8 != 16 { + return errResult("serialNumber is not approximately 128 bits") + } + + // signature is byte-for-byte identical to one of the hexadecimal encodings + // specified by Section 7.1.3.2 of the Baseline Requirements (Section + // 7.1.3.2). + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1021 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + tbsSignature, err := util.GetSignatureAlgorithmInTBSEncoded(c) + if err != nil { + return errResult("failed to parse tbsCertificate.signature") + } + if !brSignatureAlgorithmIdentifiers[hex.EncodeToString(tbsSignature)] { + return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") + } + + // issuer is "Byte-for-byte identical to the subject field of the Issuing + // CA". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1022 + if !bytes.Equal(c.RawIssuer, issuer.RawSubject) { + return errResult("issuer is not byte-for-byte identical to the subject of the configured Issuing CA") + } + + // validity is "At most 8 years". RFC 5280 4.1.2.5: "The validity period + // for a certificate is the period of time from notBefore through notAfter, + // inclusive." + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1023 + if c.NotAfter.Add(time.Second).After(c.NotBefore.AddDate(8, 0, 0)) { + return errResult("validity is more than 8 years") + } + + // subject is "Byte-for-byte identical to the subject field of the existing + // CA Certificate". The shape checks below are implied by the byte-for-byte + // comparison, but produce more useful error messages. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1024 + if !bytes.Equal(c.RawSubject, existing.RawSubject) { + return errResult("subject is not byte-for-byte identical to the subject of the configured existing CA Certificate") + } + if len(c.Subject.Names) != 3 { + return errResult("subject does not contain exactly C, O, and CN attributes") + } + if len(c.Subject.Country) != 1 || c.Subject.Country[0] != "US" { + return errResult("subject countryName is not US") + } + if c.Subject.CommonName == "" { + return errResult("subject commonName is empty") + } + + // subjectPublicKeyInfo: the subject of a cross-certificate is an existing + // ISRG root, so Section 6.1.5's Root CA requirements apply to its key: + // "either RSA keys whose encoded modulus size is 4096 bits, or ECDSA keys + // which are a valid point on the NIST P-384 elliptic curve". Section + // 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte identical + // to a BRs Section 7.1.3.1 encoding. Point-on-curve validation is + // performed by zcrypto at parse time. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1025 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L852 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + spkiAlgID, err := util.GetPublicKeyAidEncoded(c) + if err != nil { + return errResult("failed to parse subjectPublicKeyInfo algorithm") + } + switch key := c.PublicKey.(type) { + case *zrsa.PublicKey: + if key.N.BitLen() != 4096 { + return errResult(fmt.Sprintf("RSA modulus size %d is not allowed", key.N.BitLen())) + } + if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { + return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") + } + case *x509.AugmentedECDSA: + if key.Pub.Curve != elliptic.P384() { + return errResult(fmt.Sprintf("ECDSA curve %s is not allowed", key.Pub.Curve.Params().Name)) + } + if hex.EncodeToString(spkiAlgID) != spkiAlgorithmP384 { + return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 encoding for its curve") + } + default: + return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) + } + + // issuerUniqueID is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1026 + if c.IssuerUniqueId.BitLength != 0 || len(c.IssuerUniqueId.Bytes) != 0 { + return errResult("issuerUniqueID is present") + } + + // subjectUniqueID is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1027 + if c.SubjectUniqueId.BitLength != 0 || len(c.SubjectUniqueId.Bytes) != 0 { + return errResult("subjectUniqueID is present") + } + + // authorityInformationAccess "Contains the HTTP URI of the Issuing CA's + // Certificate". Whether the URI actually serves the Issuing CA's + // certificate is not observable here, but the extension must contain + // exactly one caIssuers entry with an http URI and nothing else (in + // particular, no OCSP entries). + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1029 + if !util.IsExtInCert(c, authorityInformationAccessOID) { + return errResult("authorityInformationAccess extension is not present") + } + if len(c.OCSPServer) != 0 { + return errResult("authorityInformationAccess contains an OCSP entry") + } + if len(c.IssuingCertificateURL) != 1 { + return errResult("authorityInformationAccess does not contain exactly one caIssuers entry") + } + if !strings.HasPrefix(c.IssuingCertificateURL[0], "http://") { + return errResult("authorityInformationAccess caIssuers URI does not use the http scheme") + } + + // authorityKeyIdentifier "Contains a keyIdentifier byte-for-byte identical + // to the subjectKeyIdentifier of the Issuing CA". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1030 + akidExt := getExtension(c, authorityKeyIdentifierOID) + if akidExt == nil { + return errResult("authorityKeyIdentifier extension is not present") + } + if akidExt.Critical { + return errResult("authorityKeyIdentifier extension is critical") + } + if len(c.AuthorityKeyId) == 0 { + return errResult("authorityKeyIdentifier does not contain a keyIdentifier") + } + if !bytes.Equal(c.AuthorityKeyId, issuer.SubjectKeyId) { + return errResult("authorityKeyIdentifier keyIdentifier is not byte-for-byte identical to the subjectKeyIdentifier of the configured Issuing CA") + } + + // basicConstraints is "Critical, with cA set to true and pathLenConstraint + // identical to the existing CA Certificate". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1031 + bcExt := getExtension(c, basicConstraintsOID) + if bcExt == nil { + return errResult("basicConstraints extension is not present") + } + if !bcExt.Critical { + return errResult("basicConstraints extension is not critical") + } + if !c.IsCA { + return errResult("basicConstraints cA is not true") + } + if c.MaxPathLen != existing.MaxPathLen || c.MaxPathLenZero != existing.MaxPathLenZero { + return errResult("basicConstraints pathLenConstraint is not identical to that of the configured existing CA Certificate") + } + + // certificatePolicies "Contains only the Baseline Requirements Domain + // Validated Reserved Policy Identifier (OID 2.23.140.1.2.1)". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1032 + if !util.IsExtInCert(c, certificatePoliciesOID) { + return errResult("certificatePolicies extension is not present") + } + if len(c.PolicyIdentifiers) != 1 || !c.PolicyIdentifiers[0].Equal(domainValidatedOID) { + return errResult("certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier") + } + + // crlDistributionPoints "Contains the HTTP URI of a CRL issued by the + // Issuing CA whose scope includes this certificate". Whether the CRL's + // scope actually includes this certificate is not observable here. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1033 + if !util.IsExtInCert(c, crlDistributionPointsOID) { + return errResult("crlDistributionPoints extension is not present") + } + if len(c.CRLDistributionPoints) != 1 { + return errResult("crlDistributionPoints does not contain exactly one distribution point") + } + if !strings.HasPrefix(c.CRLDistributionPoints[0], "http://") { + return errResult("crlDistributionPoints URI does not use the http scheme") + } + + // extKeyUsage "Contains only id-kp-serverAuth (OID 1.3.6.1.5.5.7.3.1)". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1034 + if !util.IsExtInCert(c, extKeyUsageOID) { + return errResult("extKeyUsage extension is not present") + } + if len(c.ExtKeyUsage) != 1 || c.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth || len(c.UnknownExtKeyUsage) != 0 { + return errResult("extKeyUsage does not contain exactly id-kp-serverAuth") + } + + // keyUsage is "Critical, with only the keyCertSign (5) and cRLSign (6) + // bits set". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1035 + kuExt := getExtension(c, keyUsageOID) + if kuExt == nil { + return errResult("keyUsage extension is not present") + } + if !kuExt.Critical { + return errResult("keyUsage extension is not critical") + } + if c.KeyUsage != x509.KeyUsageCertSign|x509.KeyUsageCRLSign { + return errResult("keyUsage does not assert exactly the bits required by the profile") + } + + // subjectKeyIdentifier is "Byte-for-byte identical to the + // subjectKeyIdentifier of the existing CA Certificate". Unlike the other + // profiles we cannot assume the RFC 7093 construction here, because the + // existing CA Certificate's subjectKeyIdentifier may have been computed by + // any method. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1036 + skidExt := getExtension(c, subjectKeyIdentifierOID) + if skidExt == nil { + return errResult("subjectKeyIdentifier extension is not present") + } + if skidExt.Critical { + return errResult("subjectKeyIdentifier extension is critical") + } + if len(c.SubjectKeyId) == 0 { + return errResult("subjectKeyIdentifier is empty") + } + if !bytes.Equal(c.SubjectKeyId, existing.SubjectKeyId) { + return errResult("subjectKeyIdentifier is not byte-for-byte identical to that of the configured existing CA Certificate") + } + + // Any other extension is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1037 + allowedExtensions := []asn1.ObjectIdentifier{ + authorityInformationAccessOID, + authorityKeyIdentifierOID, + basicConstraintsOID, + certificatePoliciesOID, + crlDistributionPointsOID, + extKeyUsageOID, + keyUsageOID, + subjectKeyIdentifierOID, + } + for _, ext := range c.Extensions { + found := false + for _, oid := range allowedExtensions { + if ext.Id.Equal(oid) { + found = true + } + } + if !found { + return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) + } + } + + // signatureAlgorithm is "Byte-for-byte identical to the + // tbsCertificate.signature". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1038 + signatureAlgorithm, err := getOuterSignatureAlgorithm(c.Raw) + if err != nil { + return errResult(err.Error()) + } + if !bytes.Equal(tbsSignature, signatureAlgorithm) { + return errResult("signatureAlgorithm is not byte-for-byte identical to the tbsCertificate.signature") + } + + // signatureValue is "A signature appropriate to the signatureAlgorithm + // field". We can't verify the signature here: pre-issuance linting + // operates on a certificate signed by a throwaway key. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1039 + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go new file mode 100644 index 00000000000..d4c91e7236a --- /dev/null +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go @@ -0,0 +1,182 @@ +package cpcps + +import ( + "crypto" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "strings" + "testing" + "time" + + "github.com/zmap/zlint/v3/lint" +) + +// testCrossCertTemplate returns a template matching the Cross-Certified +// Subordinate CA Certificate Profile from CP/CPS Section 7.1: a certificate +// conferring a second issuance path upon an existing ISRG root. +func testCrossCertTemplate(t *testing.T, pub crypto.PublicKey) *x509.Certificate { + t.Helper() + notBefore := time.Date(2025, time.November, 1, 0, 0, 0, 0, time.UTC) + return &x509.Certificate{ + SerialNumber: testSerial(t, 16), + Subject: pkix.Name{ + Country: []string{"US"}, + Organization: []string{"ISRG"}, + CommonName: "ISRG Root X100", + }, + NotBefore: notBefore, + NotAfter: notBefore.AddDate(5, 0, 0).Add(-time.Second), + // The existing CA Certificate being cross-signed is a root, which has + // no pathLenConstraint, so MaxPathLen and MaxPathLenZero are left at + // their zero values. + BasicConstraintsValid: true, + IsCA: true, + // Cross-certificates assert only the keyCertSign and cRLSign bits. + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + Policies: []x509.OID{testDomainValidatedOID}, + IssuingCertificateURL: []string{"http://x99.i.lencr.org/"}, + CRLDistributionPoints: []string{"http://x99.c.lencr.org/1.crl"}, + SubjectKeyId: testRFC7093SKID(t, pub), + } +} + +func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { + t.Parallel() + + rootKey := testKey(t, elliptic.P384()) + rootTmpl := testRootTemplate(t, rootKey.Public()) + rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, rootKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test issuer: %s", err) + } + + testCases := []struct { + name string + mod func(t *testing.T, tmpl *x509.Certificate) + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "pathlen_mismatch", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // The existing CA Certificate has no pathLenConstraint, so + // including one in the cross-certificate is a mismatch. + tmpl.MaxPathLen = 0 + tmpl.MaxPathLenZero = true + }, + want: lint.Error, + wantSubStr: "basicConstraints pathLenConstraint is not identical to that of the configured existing CA Certificate", + }, + { + name: "validity_too_long", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = tmpl.NotBefore.AddDate(9, 0, 0) + }, + want: lint.Error, + wantSubStr: "validity is more than 8 years", + }, + { + name: "extra_key_usage_bit", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.KeyUsage |= x509.KeyUsageDigitalSignature + }, + want: lint.Error, + wantSubStr: "keyUsage does not assert exactly the bits required by the profile", + }, + { + name: "missing_country", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.Country = nil + }, + want: lint.Error, + wantSubStr: "subject is not byte-for-byte identical to the subject of the configured existing CA Certificate", + }, + { + name: "extra_eku", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} + }, + want: lint.Error, + wantSubStr: "extKeyUsage does not contain exactly id-kp-serverAuth", + }, + { + name: "missing_aia", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = nil + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess extension is not present", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // The existing CA Certificate being cross-signed: self-signed by + // the same key, with the same subject, SKID, and (absent) + // pathLenConstraint as the unmodified cross-certificate template. + crossKey := testKey(t, elliptic.P384()) + existingDER, err := x509.CreateCertificate(rand.Reader, testCrossCertTemplate(t, crossKey.Public()), testCrossCertTemplate(t, crossKey.Public()), crossKey.Public(), crossKey) + if err != nil { + t.Fatalf("creating existing CA certificate: %s", err) + } + + tmpl := testCrossCertTemplate(t, crossKey.Public()) + if tc.mod != nil { + tc.mod(t, tmpl) + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, rootTmpl, crossKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + cert := testParseZCert(t, der) + + l := &crossCertifiedSubordinateCACertificateMatchesCPSProfile{Config: &SharedConfig{ + IssuerCertificatePEM: testPEM(t, rootDER), + ExistingCertificatePEM: testPEM(t, existingDER), + }} + if !l.CheckApplies(cert) { + t.Fatal("lint does not apply to test certificate") + } + + res := l.Execute(cert) + if res.Status != tc.want { + t.Errorf("got status %s (%q), want %s", res.Status, res.Details, tc.want) + } + if !strings.Contains(res.Details, tc.wantSubStr) { + t.Errorf("got details %q, want substring %q", res.Details, tc.wantSubStr) + } + }) + } +} + +func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfileCheckApplies(t *testing.T) { + t.Parallel() + + rootKey := testKey(t, elliptic.P384()) + rootTmpl := testRootTemplate(t, rootKey.Public()) + + // A TLS subordinate CA certificate (subject O=Let's Encrypt) is covered by + // the TLS subordinate profile, not this one. + intKey := testKey(t, elliptic.P384()) + intTmpl := testIntermediateTemplate(t, intKey.Public()) + + der, err := x509.CreateCertificate(rand.Reader, intTmpl, rootTmpl, intKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + + l := NewCrossCertifiedSubordinateCACertificateMatchesCPSProfile() + if l.CheckApplies(testParseZCert(t, der)) { + t.Error("lint applies to TLS subordinate CA certificate") + } +} diff --git a/linter/lints/cpcps/lint_precertificate.go b/linter/lints/cpcps/lint_precertificate.go new file mode 100644 index 00000000000..5df100f1f38 --- /dev/null +++ b/linter/lints/cpcps/lint_precertificate.go @@ -0,0 +1,101 @@ +package cpcps + +import ( + "fmt" + + "github.com/zmap/zcrypto/encoding/asn1" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type precertificateMatchesCPSProfile struct { + // Config is filled from the shared [Global] stanza of the lint + // configuration; see IssuingCAConfig. + Config *SharedConfig +} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_precertificate_matches_cps_profile", + Description: "Let's Encrypt Precertificates are issued in accordance with the CP/CPS Profile", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV62Date, + }, + Lint: NewPrecertificateMatchesCPSProfile, + }) +} + +func NewPrecertificateMatchesCPSProfile() lint.CertificateLintInterface { + return &precertificateMatchesCPSProfile{} +} + +// Configure implements the lint.Configurable interface. +func (l *precertificateMatchesCPSProfile) Configure() any { + return l +} + +func (l *precertificateMatchesCPSProfile) CheckApplies(c *x509.Certificate) bool { + return util.IsSubscriberCert(c) && util.IsServerAuthCert(c) && util.IsExtInCert(c, util.CtPoisonOID) +} + +// Execute checks the given precertificate against the Precertificate Profile, +// which is "Identical to the Subscriber (Server) Certificate Profile, except +// that the SignedCertificateTimestampList extension is omitted, and a critical +// 'CT poison' extension (OID 1.3.6.1.4.1.11129.2.4.3) is included": first the +// rows shared with the Subscriber (Server) Certificate Profile (whose +// implementation lives in lint_subscriber_server_certificate.go), then the +// rows specific to precertificates. +// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1097-L1099 +func (l *precertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { + res := checkSubscriberProfile(c, l.Config.issuerPEM()) + if res != nil { + return res + } + + // In place of the SignedCertificateTimestampList extension, a critical + // "CT poison" extension (OID 1.3.6.1.4.1.11129.2.4.3) is included. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1099 + poisonExt := getExtension(c, util.CtPoisonOID) + if poisonExt == nil { + return errResult("CT poison extension is not present") + } + if !poisonExt.Critical { + return errResult("CT poison extension is not critical") + } + + // Any other extension is "Not present". In particular, the + // SignedCertificateTimestampList extension is omitted from + // precertificates, so it is not in the allowed set. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1093 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1099 + allowedExtensions := []asn1.ObjectIdentifier{ + authorityInformationAccessOID, + authorityKeyIdentifierOID, + basicConstraintsOID, + certificatePoliciesOID, + crlDistributionPointsOID, + extKeyUsageOID, + keyUsageOID, + util.CtPoisonOID, + subjectAltNameOID, + subjectKeyIdentifierOID, + } + for _, ext := range c.Extensions { + found := false + for _, oid := range allowedExtensions { + if ext.Id.Equal(oid) { + found = true + } + } + if !found { + return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) + } + } + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/linter/lints/cpcps/lint_precertificate_test.go b/linter/lints/cpcps/lint_precertificate_test.go new file mode 100644 index 00000000000..c28bf525abb --- /dev/null +++ b/linter/lints/cpcps/lint_precertificate_test.go @@ -0,0 +1,136 @@ +package cpcps + +import ( + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" +) + +// testPrecertTemplate returns a template matching the Precertificate Profile +// from CP/CPS Section 7.1: the Subscriber (Server) Certificate Profile with +// the SignedCertificateTimestampList extension replaced by a critical CT +// poison extension. +func testPrecertTemplate(t *testing.T) *x509.Certificate { + t.Helper() + tmpl := testLeafTemplate(t) + tmpl.ExtraExtensions = []pkix.Extension{ + {Id: testCTPoisonOID, Critical: true, Value: []byte{0x05, 0x00}}, + } + return tmpl +} + +func TestPrecertificateMatchesCPSProfile(t *testing.T) { + t.Parallel() + + intKey := testKey(t, elliptic.P384()) + intTmpl := testIntermediateTemplate(t, intKey.Public()) + intDER, err := x509.CreateCertificate(rand.Reader, intTmpl, intTmpl, intKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test issuer: %s", err) + } + + testCases := []struct { + name string + mod func(t *testing.T, tmpl *x509.Certificate) + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "poison_not_critical", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtraExtensions = []pkix.Extension{ + {Id: testCTPoisonOID, Critical: false, Value: []byte{0x05, 0x00}}, + } + }, + want: lint.Error, + wantSubStr: "CT poison extension is not critical", + }, + { + name: "scts_alongside_poison", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, + testSCTListExtension(t, [32]byte{1}, [32]byte{2})) + }, + want: lint.Error, + wantSubStr: "unexpected extension", + }, + { + name: "validity_too_long", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = tmpl.NotBefore.AddDate(0, 0, 101) + }, + want: lint.Error, + wantSubStr: "validity is more than 100 days", + }, + { + name: "serial_too_short", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.SerialNumber = testSerial(t, 16) + }, + want: lint.Error, + wantSubStr: "serialNumber is not approximately 144 bits", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + leafKey := testKey(t, elliptic.P256()) + tmpl := testPrecertTemplate(t) + if tc.mod != nil { + tc.mod(t, tmpl) + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, intTmpl, leafKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + cert := testParseZCert(t, der) + + l := &precertificateMatchesCPSProfile{Config: &SharedConfig{IssuerCertificatePEM: testPEM(t, intDER)}} + if !l.CheckApplies(cert) { + t.Fatal("lint does not apply to test certificate") + } + + res := l.Execute(cert) + if res.Status != tc.want { + t.Errorf("got status %s (%q), want %s", res.Status, res.Details, tc.want) + } + if !strings.Contains(res.Details, tc.wantSubStr) { + t.Errorf("got details %q, want substring %q", res.Details, tc.wantSubStr) + } + }) + } +} + +func TestPrecertificateMatchesCPSProfileCheckApplies(t *testing.T) { + t.Parallel() + + intKey := testKey(t, elliptic.P384()) + intTmpl := testIntermediateTemplate(t, intKey.Public()) + + // A final certificate (without the CT poison extension) is covered by the + // subscriber server certificate profile, not this one. + leafKey := testKey(t, elliptic.P256()) + tmpl := testLeafTemplate(t) + + der, err := x509.CreateCertificate(rand.Reader, tmpl, intTmpl, leafKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + + l := NewPrecertificateMatchesCPSProfile() + if l.CheckApplies(testParseZCert(t, der)) { + t.Error("lint applies to final (non-poisoned) certificate") + } +} diff --git a/linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go b/linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go deleted file mode 100644 index a963cf1958f..00000000000 --- a/linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go +++ /dev/null @@ -1,49 +0,0 @@ -package cpcps - -import ( - "time" - - "github.com/zmap/zcrypto/x509" - "github.com/zmap/zlint/v3/lint" - "github.com/zmap/zlint/v3/util" - - "github.com/letsencrypt/boulder/linter/lints" -) - -type rootCACertValidityTooLong struct{} - -func init() { - lint.RegisterCertificateLint(&lint.CertificateLint{ - LintMetadata: lint.LintMetadata{ - Name: "e_root_ca_cert_validity_period_greater_than_25_years", - Description: "Let's Encrypt Root CA Certificates have Validity Periods of up to 25 years", - Citation: "CPS: 7.1", - Source: lints.LetsEncryptCPS, - EffectiveDate: lints.CPSV33Date, - }, - Lint: NewRootCACertValidityTooLong, - }) -} - -func NewRootCACertValidityTooLong() lint.CertificateLintInterface { - return &rootCACertValidityTooLong{} -} - -func (l *rootCACertValidityTooLong) CheckApplies(c *x509.Certificate) bool { - return util.IsRootCA(c) -} - -func (l *rootCACertValidityTooLong) Execute(c *x509.Certificate) *lint.LintResult { - // CPS 7.1: "Root CA Certificate Validity Period: Up to 25 years." - maxValidity := 25 * 365 * lints.BRDay - - // RFC 5280 4.1.2.5: "The validity period for a certificate is the period - // of time from notBefore through notAfter, inclusive." - certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) - - if certValidity > maxValidity { - return &lint.LintResult{Status: lint.Error} - } - - return &lint.LintResult{Status: lint.Pass} -} diff --git a/linter/lints/cpcps/lint_root_ca_certificate.go b/linter/lints/cpcps/lint_root_ca_certificate.go new file mode 100644 index 00000000000..5479028b974 --- /dev/null +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -0,0 +1,244 @@ +package cpcps + +import ( + "bytes" + "crypto/elliptic" + stdx509 "crypto/x509" + "encoding/hex" + "fmt" + "time" + + "github.com/zmap/zcrypto/encoding/asn1" + zrsa "github.com/zmap/zcrypto/rsa" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/linter/lints" +) + +type rootCACertificateMatchesCPSProfile struct{} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_root_ca_certificate_matches_cps_profile", + Description: "Let's Encrypt Root CA Certificates are issued in accordance with the CP/CPS Profile", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV62Date, + }, + Lint: NewRootCACertificateMatchesCPSProfile, + }) +} + +func NewRootCACertificateMatchesCPSProfile() lint.CertificateLintInterface { + return &rootCACertificateMatchesCPSProfile{} +} + +func (l *rootCACertificateMatchesCPSProfile) CheckApplies(c *x509.Certificate) bool { + return util.IsRootCA(c) +} + +// Execute checks the given certificate against the Root CA Certificate +// Profile, row by row. Root CA Certificates are self-signed, so unlike the +// other profiles there is no Issuing CA to be configured with: the issuer +// correspondence is checked against the certificate's own subject. +// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L992-L1012 +func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { + // version is "X.509 version 3" (Section 7.1.1). Note that unlike + // crypto/x509, zcrypto's Version field is one-indexed: it holds 3 (not the + // raw encoded value 2) for a v3 certificate. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L997 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1101-L1103 + if c.Version != 3 { + return errResult("version is not v3") + } + + // serialNumber is "Approximately 128 bits, including at least 64 bits of + // output from a CSPRNG". We can't test randomness here, but a 128-bit + // serial occupies exactly 16 bytes. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L998 + if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { + return errResult("serialNumber is not a positive integer") + } + if (c.SerialNumber.BitLen()+7)/8 != 16 { + return errResult("serialNumber is not approximately 128 bits") + } + + // signature is byte-for-byte identical to one of the hexadecimal encodings + // specified by Section 7.1.3.2 of the Baseline Requirements (Section + // 7.1.3.2). + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L999 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + tbsSignature, err := util.GetSignatureAlgorithmInTBSEncoded(c) + if err != nil { + return errResult("failed to parse tbsCertificate.signature") + } + if !brSignatureAlgorithmIdentifiers[hex.EncodeToString(tbsSignature)] { + return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") + } + + // issuer is "Byte-for-byte identical to the subject field". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1000 + if !bytes.Equal(c.RawIssuer, c.RawSubject) { + return errResult("issuer is not byte-for-byte identical to the subject") + } + + // validity is "At most 9132 days". RFC 5280 4.1.2.5: "The validity period + // for a certificate is the period of time from notBefore through notAfter, + // inclusive." + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1001 + if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 9132*lints.BRDay { + return errResult("validity is more than 9132 days") + } + + // subject is "C=US, O=ISRG, and a unique CN". + // We can't test for CN uniqueness here, but the rest we can check. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1002 + if len(c.Subject.Names) != 3 { + return errResult("subject does not contain exactly C, O, and CN attributes") + } + if len(c.Subject.Country) != 1 || c.Subject.Country[0] != "US" { + return errResult("subject countryName is not US") + } + if len(c.Subject.Organization) != 1 || c.Subject.Organization[0] != "ISRG" { + return errResult("subject organizationName is not ISRG") + } + if c.Subject.CommonName == "" { + return errResult("subject commonName is empty") + } + + // subjectPublicKeyInfo: Section 6.1.5 says Root CA key pairs are "either + // RSA keys whose encoded modulus size is 4096 bits, or ECDSA keys which + // are a valid point on the NIST P-384 elliptic curve", and Section 7.1.3.1 + // requires the AlgorithmIdentifier to be byte-for-byte identical to a BRs + // Section 7.1.3.1 encoding. Point-on-curve validation is performed by + // zcrypto at parse time. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1003 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L852 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + spkiAlgID, err := util.GetPublicKeyAidEncoded(c) + if err != nil { + return errResult("failed to parse subjectPublicKeyInfo algorithm") + } + switch key := c.PublicKey.(type) { + case *zrsa.PublicKey: + if key.N.BitLen() != 4096 { + return errResult(fmt.Sprintf("RSA modulus size %d is not allowed", key.N.BitLen())) + } + if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { + return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") + } + case *x509.AugmentedECDSA: + if key.Pub.Curve != elliptic.P384() { + return errResult(fmt.Sprintf("ECDSA curve %s is not allowed", key.Pub.Curve.Params().Name)) + } + if hex.EncodeToString(spkiAlgID) != spkiAlgorithmP384 { + return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 encoding for its curve") + } + default: + return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) + } + + // issuerUniqueID is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1004 + if c.IssuerUniqueId.BitLength != 0 || len(c.IssuerUniqueId.Bytes) != 0 { + return errResult("issuerUniqueID is present") + } + + // subjectUniqueID is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1005 + if c.SubjectUniqueId.BitLength != 0 || len(c.SubjectUniqueId.Bytes) != 0 { + return errResult("subjectUniqueID is present") + } + + // basicConstraints is "Critical, with cA set to true". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1007 + bcExt := getExtension(c, basicConstraintsOID) + if bcExt == nil { + return errResult("basicConstraints extension is not present") + } + if !bcExt.Critical { + return errResult("basicConstraints extension is not critical") + } + if !c.IsCA { + return errResult("basicConstraints cA is not true") + } + + // keyUsage is "Critical, with only the keyCertSign (5) and cRLSign (6) + // bits set". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1008 + kuExt := getExtension(c, keyUsageOID) + if kuExt == nil { + return errResult("keyUsage extension is not present") + } + if !kuExt.Critical { + return errResult("keyUsage extension is not critical") + } + if c.KeyUsage != x509.KeyUsageCertSign|x509.KeyUsageCRLSign { + return errResult("keyUsage does not assert exactly the bits required by the profile") + } + + // subjectKeyIdentifier "Contains a truncated hash of the subjectPublicKey, + // per Section 2(1) of RFC 7093". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1009 + skidExt := getExtension(c, subjectKeyIdentifierOID) + if skidExt == nil { + return errResult("subjectKeyIdentifier extension is not present") + } + if skidExt.Critical { + return errResult("subjectKeyIdentifier extension is critical") + } + subjectPublicKey, err := stdx509.ParsePKIXPublicKey(c.RawSubjectPublicKeyInfo) + if err != nil { + return errResult("failed to parse subjectPublicKey") + } + // core.GenerateSKID implements the RFC 7093 Section 2(1) method. + expectedSKID, err := core.GenerateSKID(subjectPublicKey) + if err != nil { + return errResult("failed to compute subjectKeyIdentifier from the subjectPublicKey") + } + if !bytes.Equal(c.SubjectKeyId, expectedSKID) { + return errResult("subjectKeyIdentifier is not the RFC 7093 Section 2(1) truncated hash of the subjectPublicKey") + } + + // Any other extension is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1010 + allowedExtensions := []asn1.ObjectIdentifier{ + basicConstraintsOID, + keyUsageOID, + subjectKeyIdentifierOID, + } + for _, ext := range c.Extensions { + found := false + for _, oid := range allowedExtensions { + if ext.Id.Equal(oid) { + found = true + } + } + if !found { + return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) + } + } + + // signatureAlgorithm is "Byte-for-byte identical to the + // tbsCertificate.signature". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1011 + signatureAlgorithm, err := getOuterSignatureAlgorithm(c.Raw) + if err != nil { + return errResult(err.Error()) + } + if !bytes.Equal(tbsSignature, signatureAlgorithm) { + return errResult("signatureAlgorithm is not byte-for-byte identical to the tbsCertificate.signature") + } + + // signatureValue is "A signature appropriate to the signatureAlgorithm + // field". We can't verify the signature under pre-issuance linting (the + // certificate is signed by a throwaway key), but CheckApplies only selects + // certificates whose self-signature zcrypto has verified. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1012 + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/linter/lints/cpcps/lint_root_ca_certificate_test.go b/linter/lints/cpcps/lint_root_ca_certificate_test.go new file mode 100644 index 00000000000..9b0b4c17d22 --- /dev/null +++ b/linter/lints/cpcps/lint_root_ca_certificate_test.go @@ -0,0 +1,183 @@ +package cpcps + +import ( + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "strings" + "testing" + "time" + + "github.com/zmap/zlint/v3/lint" +) + +func TestRootCACertificateMatchesCPSProfile(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + curve elliptic.Curve + mod func(t *testing.T, tmpl *x509.Certificate) + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "good_notafter_before_2050", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = time.Date(2049, time.June, 1, 0, 0, 0, 0, time.UTC) + }, + want: lint.Pass, + }, + { + name: "serial_too_short", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.SerialNumber = big.NewInt(12345) + }, + want: lint.Error, + wantSubStr: "serialNumber is not approximately 128 bits", + }, + { + name: "validity_too_long", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = tmpl.NotAfter.Add(24 * time.Hour) + }, + want: lint.Error, + wantSubStr: "validity is more than 9132 days", + }, + { + name: "wrong_organization", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.Organization = []string{"Internet Security Research Group"} + }, + want: lint.Error, + wantSubStr: "subject organizationName is not ISRG", + }, + { + name: "missing_country", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.Country = nil + }, + want: lint.Error, + wantSubStr: "subject does not contain exactly C, O, and CN attributes", + }, + { + name: "missing_common_name", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.CommonName = "" + }, + want: lint.Error, + wantSubStr: "subject does not contain exactly C, O, and CN attributes", + }, + { + name: "extra_subject_attribute", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.OrganizationalUnit = []string{"Roots"} + }, + want: lint.Error, + wantSubStr: "subject does not contain exactly C, O, and CN attributes", + }, + { + name: "wrong_curve", + curve: elliptic.P256(), + want: lint.Error, + wantSubStr: "ECDSA curve P-256 is not allowed", + }, + { + name: "extra_key_usage_bit", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.KeyUsage |= x509.KeyUsageDigitalSignature + }, + want: lint.Error, + wantSubStr: "keyUsage does not assert exactly the bits required by the profile", + }, + { + name: "missing_crl_sign", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.KeyUsage = x509.KeyUsageCertSign + }, + want: lint.Error, + wantSubStr: "keyUsage does not assert exactly the bits required by the profile", + }, + { + name: "wrong_skid", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.SubjectKeyId = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + }, + want: lint.Error, + wantSubStr: "subjectKeyIdentifier is not the RFC 7093", + }, + { + name: "extra_extension", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtraExtensions = []pkix.Extension{ + {Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 44947, 999}, Value: []byte{0x05, 0x00}}, + } + }, + want: lint.Error, + wantSubStr: "unexpected extension", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + curve := tc.curve + if curve == nil { + curve = elliptic.P384() + } + key := testKey(t, curve) + + tmpl := testRootTemplate(t, key.Public()) + if tc.mod != nil { + tc.mod(t, tmpl) + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + cert := testParseZCert(t, der) + + l := NewRootCACertificateMatchesCPSProfile() + if !l.CheckApplies(cert) { + t.Fatal("lint does not apply to test certificate") + } + + res := l.Execute(cert) + if res.Status != tc.want { + t.Errorf("got status %s (%q), want %s", res.Status, res.Details, tc.want) + } + if !strings.Contains(res.Details, tc.wantSubStr) { + t.Errorf("got details %q, want substring %q", res.Details, tc.wantSubStr) + } + }) + } +} + +func TestRootCACertificateMatchesCPSProfileCheckApplies(t *testing.T) { + t.Parallel() + + rootKey := testKey(t, elliptic.P384()) + rootTmpl := testRootTemplate(t, rootKey.Public()) + + intKey := testKey(t, elliptic.P384()) + intTmpl := testIntermediateTemplate(t, intKey.Public()) + + intDER, err := x509.CreateCertificate(rand.Reader, intTmpl, rootTmpl, intKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + + l := NewRootCACertificateMatchesCPSProfile() + if l.CheckApplies(testParseZCert(t, intDER)) { + t.Error("lint applies to non-self-signed intermediate certificate") + } +} diff --git a/linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go b/linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go deleted file mode 100644 index fdf5906c984..00000000000 --- a/linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go +++ /dev/null @@ -1,49 +0,0 @@ -package cpcps - -import ( - "time" - - "github.com/zmap/zcrypto/x509" - "github.com/zmap/zlint/v3/lint" - "github.com/zmap/zlint/v3/util" - - "github.com/letsencrypt/boulder/linter/lints" -) - -type subordinateCACertValidityTooLong struct{} - -func init() { - lint.RegisterCertificateLint(&lint.CertificateLint{ - LintMetadata: lint.LintMetadata{ - Name: "e_validity_period_greater_than_8_years", - Description: "Let's Encrypt Intermediate CA Certificates have Validity Periods of up to 8 years", - Citation: "CPS: 7.1", - Source: lints.LetsEncryptCPS, - EffectiveDate: lints.CPSV33Date, - }, - Lint: NewSubordinateCACertValidityTooLong, - }) -} - -func NewSubordinateCACertValidityTooLong() lint.CertificateLintInterface { - return &subordinateCACertValidityTooLong{} -} - -func (l *subordinateCACertValidityTooLong) CheckApplies(c *x509.Certificate) bool { - return util.IsSubCA(c) -} - -func (l *subordinateCACertValidityTooLong) Execute(c *x509.Certificate) *lint.LintResult { - // CPS 7.1: "Intermediate CA Certificate Validity Period: Up to 8 years." - maxValidity := 8 * 365 * lints.BRDay - - // RFC 5280 4.1.2.5: "The validity period for a certificate is the period - // of time from notBefore through notAfter, inclusive." - certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) - - if certValidity > maxValidity { - return &lint.LintResult{Status: lint.Error} - } - - return &lint.LintResult{Status: lint.Pass} -} diff --git a/linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go b/linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go deleted file mode 100644 index e91e187c41e..00000000000 --- a/linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go +++ /dev/null @@ -1,49 +0,0 @@ -package cpcps - -import ( - "time" - - "github.com/zmap/zcrypto/x509" - "github.com/zmap/zlint/v3/lint" - "github.com/zmap/zlint/v3/util" - - "github.com/letsencrypt/boulder/linter/lints" -) - -type subscriberCertValidityTooLong struct{} - -func init() { - lint.RegisterCertificateLint(&lint.CertificateLint{ - LintMetadata: lint.LintMetadata{ - Name: "e_subscriber_cert_validity_period_greater_than_100_days", - Description: "Let's Encrypt Subscriber Certificates have Validity Periods of up to 100 days", - Citation: "CPS: 7.1", - Source: lints.LetsEncryptCPS, - EffectiveDate: lints.CPSV33Date, - }, - Lint: NewSubscriberCertValidityTooLong, - }) -} - -func NewSubscriberCertValidityTooLong() lint.CertificateLintInterface { - return &subscriberCertValidityTooLong{} -} - -func (l *subscriberCertValidityTooLong) CheckApplies(c *x509.Certificate) bool { - return util.IsServerAuthCert(c) && !c.IsCA -} - -func (l *subscriberCertValidityTooLong) Execute(c *x509.Certificate) *lint.LintResult { - // CPS 7.1: "DV SSL End Entity Certificate Validity Period: Up to 100 days." - maxValidity := 100 * lints.BRDay - - // RFC 5280 4.1.2.5: "The validity period for a certificate is the period - // of time from notBefore through notAfter, inclusive." - certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) - - if certValidity > maxValidity { - return &lint.LintResult{Status: lint.Error} - } - - return &lint.LintResult{Status: lint.Pass} -} diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate.go b/linter/lints/cpcps/lint_subscriber_server_certificate.go new file mode 100644 index 00000000000..86b9104f451 --- /dev/null +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -0,0 +1,428 @@ +package cpcps + +import ( + "bytes" + "crypto/elliptic" + stdx509 "crypto/x509" + "encoding/hex" + "fmt" + "net" + "slices" + "strings" + "time" + + "github.com/zmap/zcrypto/encoding/asn1" + zrsa "github.com/zmap/zcrypto/rsa" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zcrypto/x509/ct" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/linter/lints" +) + +type subscriberServerCertificateMatchesCPSProfile struct { + // Config is filled from the shared [Global] stanza of the lint + // configuration; see IssuingCAConfig. + Config *SharedConfig +} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_subscriber_server_certificate_matches_cps_profile", + Description: "Let's Encrypt Subscriber Server Certificates are issued in accordance with the CP/CPS Profile", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV62Date, + }, + Lint: NewSubscriberServerCertificateMatchesCPSProfile, + }) +} + +func NewSubscriberServerCertificateMatchesCPSProfile() lint.CertificateLintInterface { + return &subscriberServerCertificateMatchesCPSProfile{} +} + +// Configure implements the lint.Configurable interface. +func (l *subscriberServerCertificateMatchesCPSProfile) Configure() any { + return l +} + +func (l *subscriberServerCertificateMatchesCPSProfile) CheckApplies(c *x509.Certificate) bool { + // Precertificates are covered by the Precertificate Profile instead. + return util.IsSubscriberCert(c) && util.IsServerAuthCert(c) && !util.IsExtInCert(c, util.CtPoisonOID) +} + +// Execute checks the given certificate against the Subscriber (Server) +// Certificate Profile: first the rows shared with the Precertificate Profile, +// then the rows specific to final certificates. +// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1068-L1095 +func (l *subscriberServerCertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { + res := checkSubscriberProfile(c, l.Config.issuerPEM()) + if res != nil { + return res + } + + // SignedCertificateTimestampList "Contains at least two SCTs from logs + // run by different operators". We can't map log IDs to operators here, + // but SCTs from different operators necessarily come from different + // logs, so we can check that at least two distinct logs are present. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1090 + if !util.IsExtInCert(c, sctListOID) { + return errResult("signedCertificateTimestampList extension is not present") + } + logIDs := make(map[ct.SHA256Hash]bool) + for _, sct := range c.SignedCertificateTimestampList { + logIDs[sct.LogID] = true + } + if len(logIDs) < 2 { + return errResult("signedCertificateTimestampList does not contain SCTs from at least two distinct logs") + } + + // Any other extension is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1093 + allowedExtensions := []asn1.ObjectIdentifier{ + authorityInformationAccessOID, + authorityKeyIdentifierOID, + basicConstraintsOID, + certificatePoliciesOID, + crlDistributionPointsOID, + extKeyUsageOID, + keyUsageOID, + sctListOID, + subjectAltNameOID, + subjectKeyIdentifierOID, + } + for _, ext := range c.Extensions { + found := false + for _, oid := range allowedExtensions { + if ext.Id.Equal(oid) { + found = true + } + } + if !found { + return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) + } + } + + return &lint.LintResult{Status: lint.Pass} +} + +// checkSubscriberProfile enforces the profile rows shared by the Subscriber +// (Server) Certificate Profile and the Precertificate Profile, which per +// CP/CPS Section 7.1 is "Identical to the Subscriber (Server) Certificate +// Profile, except that the SignedCertificateTimestampList extension is +// omitted, and a critical 'CT poison' extension (OID 1.3.6.1.4.1.11129.2.4.3) +// is included". It returns nil if every shared row passes; the rows which +// differ between the two profiles (SignedCertificateTimestampList or CT +// poison, and the set of permitted extensions) are checked by each lint's +// Execute method. +// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1068-L1099 +func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintResult { + // Several rows of the profile require byte-for-byte correspondence with + // the Issuing CA's certificate, so this lint must be configured with it. + issuer, err := parseConfiguredCertificate(issuerPEM) + if err != nil { + return fatalResult(err.Error()) + } + if issuer == nil { + return fatalResult("lint has not been configured with the Issuing CA's certificate (issuer_certificate)") + } + + // version is "X.509 version 3" (Section 7.1.1). Note that unlike + // crypto/x509, zcrypto's Version field is one-indexed: it holds 3 (not the + // raw encoded value 2) for a v3 certificate. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1073 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1101-L1103 + if c.Version != 3 { + return errResult("version is not v3") + } + + // serialNumber is "Approximately 144 bits, including at least 64 bits of + // output from a CSPRNG". We can't test randomness here, but a 144-bit + // serial occupies exactly 18 bytes. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1074 + if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { + return errResult("serialNumber is not a positive integer") + } + if (c.SerialNumber.BitLen()+7)/8 != 18 { + return errResult("serialNumber is not approximately 144 bits") + } + + // signature is byte-for-byte identical to one of the hexadecimal encodings + // specified by Section 7.1.3.2 of the Baseline Requirements (Section + // 7.1.3.2). + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1075 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + tbsSignature, err := util.GetSignatureAlgorithmInTBSEncoded(c) + if err != nil { + return errResult("failed to parse tbsCertificate.signature") + } + if !brSignatureAlgorithmIdentifiers[hex.EncodeToString(tbsSignature)] { + return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") + } + + // issuer is "Byte-for-byte identical to the subject field of the Issuing + // CA". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1076 + if !bytes.Equal(c.RawIssuer, issuer.RawSubject) { + return errResult("issuer is not byte-for-byte identical to the subject of the configured Issuing CA") + } + + // validity is "At most 100 days". RFC 5280 4.1.2.5: "The validity period + // for a certificate is the period of time from notBefore through notAfter, + // inclusive." + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1077 + if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 100*lints.BRDay { + return errResult("validity is more than 100 days") + } + + // subject is "CN omitted, or optionally contains one of the values from + // the Subject Alternative Name extension". Per Section 7.1.4, no other + // subject attributes are included in Subscriber Certificates. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1078 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1119-L1121 + for _, atv := range c.Subject.Names { + if !atv.Type.Equal(commonNameOID) { + return errResult(fmt.Sprintf("subject contains an attribute other than commonName: %s", atv.Type.String())) + } + } + if c.Subject.CommonName != "" { + cnIP := net.ParseIP(c.Subject.CommonName) + if cnIP != nil { + found := false + for _, ip := range c.IPAddresses { + if ip.Equal(cnIP) { + found = true + } + } + if !found { + return errResult("subject commonName is not one of the subjectAltName ipAddress values") + } + } else if !slices.Contains(c.DNSNames, c.Subject.CommonName) { + return errResult("subject commonName is not one of the subjectAltName dNSName values") + } + } + + // subjectPublicKeyInfo: Section 6.1.5 says public keys in Subscriber + // Certificates are "either RSA keys whose encoded modulus size is 2048, + // 3072, or 4096 bits; or ECDSA keys which are a valid point on the NIST + // P-256, P-384, or P-521 elliptic curves", and Section 7.1.3.1 requires + // the AlgorithmIdentifier to be byte-for-byte identical to a BRs Section + // 7.1.3.1 encoding. Point-on-curve validation is performed by zcrypto at + // parse time. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1079 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L856 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + spkiAlgID, err := util.GetPublicKeyAidEncoded(c) + if err != nil { + return errResult("failed to parse subjectPublicKeyInfo algorithm") + } + switch key := c.PublicKey.(type) { + case *zrsa.PublicKey: + if key.N.BitLen() != 2048 && key.N.BitLen() != 3072 && key.N.BitLen() != 4096 { + return errResult(fmt.Sprintf("RSA modulus size %d is not allowed", key.N.BitLen())) + } + if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { + return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") + } + case *x509.AugmentedECDSA: + var wantAlgID string + switch key.Pub.Curve { + case elliptic.P256(): + wantAlgID = spkiAlgorithmP256 + case elliptic.P384(): + wantAlgID = spkiAlgorithmP384 + case elliptic.P521(): + wantAlgID = spkiAlgorithmP521 + default: + return errResult(fmt.Sprintf("ECDSA curve %s is not allowed", key.Pub.Curve.Params().Name)) + } + if hex.EncodeToString(spkiAlgID) != wantAlgID { + return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 encoding for its curve") + } + default: + return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) + } + + // issuerUniqueID is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1080 + if c.IssuerUniqueId.BitLength != 0 || len(c.IssuerUniqueId.Bytes) != 0 { + return errResult("issuerUniqueID is present") + } + + // subjectUniqueID is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1081 + if c.SubjectUniqueId.BitLength != 0 || len(c.SubjectUniqueId.Bytes) != 0 { + return errResult("subjectUniqueID is present") + } + + // authorityInformationAccess "Contains the HTTP URI of the Issuing CA's + // Certificate". Whether the URI actually serves the Issuing CA's + // certificate is not observable here, but the extension must contain + // exactly one caIssuers entry with an http URI and nothing else (in + // particular, no OCSP entries). + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1083 + if !util.IsExtInCert(c, authorityInformationAccessOID) { + return errResult("authorityInformationAccess extension is not present") + } + if len(c.OCSPServer) != 0 { + return errResult("authorityInformationAccess contains an OCSP entry") + } + if len(c.IssuingCertificateURL) != 1 { + return errResult("authorityInformationAccess does not contain exactly one caIssuers entry") + } + if !strings.HasPrefix(c.IssuingCertificateURL[0], "http://") { + return errResult("authorityInformationAccess caIssuers URI does not use the http scheme") + } + + // authorityKeyIdentifier "Contains a keyIdentifier byte-for-byte identical + // to the subjectKeyIdentifier of the Issuing CA". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1084 + akidExt := getExtension(c, authorityKeyIdentifierOID) + if akidExt == nil { + return errResult("authorityKeyIdentifier extension is not present") + } + if akidExt.Critical { + return errResult("authorityKeyIdentifier extension is critical") + } + if len(c.AuthorityKeyId) == 0 { + return errResult("authorityKeyIdentifier does not contain a keyIdentifier") + } + if !bytes.Equal(c.AuthorityKeyId, issuer.SubjectKeyId) { + return errResult("authorityKeyIdentifier keyIdentifier is not byte-for-byte identical to the subjectKeyIdentifier of the configured Issuing CA") + } + + // basicConstraints is "Critical, with cA set to false". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1085 + bcExt := getExtension(c, basicConstraintsOID) + if bcExt == nil { + return errResult("basicConstraints extension is not present") + } + if !bcExt.Critical { + return errResult("basicConstraints extension is not critical") + } + if c.IsCA { + return errResult("basicConstraints cA is not false") + } + + // certificatePolicies "Contains only the Baseline Requirements Domain + // Validated Reserved Policy Identifier (OID 2.23.140.1.2.1)". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1086 + if !util.IsExtInCert(c, certificatePoliciesOID) { + return errResult("certificatePolicies extension is not present") + } + if len(c.PolicyIdentifiers) != 1 || !c.PolicyIdentifiers[0].Equal(domainValidatedOID) { + return errResult("certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier") + } + + // crlDistributionPoints "Contains the HTTP URI of a CRL issued by the + // Issuing CA whose scope includes this certificate". Whether the CRL's + // scope actually includes this certificate is not observable here. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1087 + if !util.IsExtInCert(c, crlDistributionPointsOID) { + return errResult("crlDistributionPoints extension is not present") + } + if len(c.CRLDistributionPoints) != 1 { + return errResult("crlDistributionPoints does not contain exactly one distribution point") + } + if !strings.HasPrefix(c.CRLDistributionPoints[0], "http://") { + return errResult("crlDistributionPoints URI does not use the http scheme") + } + + // extKeyUsage "Contains only id-kp-serverAuth (OID 1.3.6.1.5.5.7.3.1)". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1088 + if !util.IsExtInCert(c, extKeyUsageOID) { + return errResult("extKeyUsage extension is not present") + } + if len(c.ExtKeyUsage) != 1 || c.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth || len(c.UnknownExtKeyUsage) != 0 { + return errResult("extKeyUsage does not contain exactly id-kp-serverAuth") + } + + // keyUsage is "Critical, with only the digitalSignature (0) bit (and the + // keyEncipherment (2) bit, for RSA keys) set". We read the parenthetical + // as permitting, not requiring, keyEncipherment for RSA keys. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1089 + kuExt := getExtension(c, keyUsageOID) + if kuExt == nil { + return errResult("keyUsage extension is not present") + } + if !kuExt.Critical { + return errResult("keyUsage extension is not critical") + } + if c.KeyUsage&x509.KeyUsageDigitalSignature == 0 { + return errResult("keyUsage does not assert digitalSignature") + } + allowedKeyUsage := x509.KeyUsageDigitalSignature + _, isRSA := c.PublicKey.(*zrsa.PublicKey) + if isRSA { + allowedKeyUsage |= x509.KeyUsageKeyEncipherment + } + if c.KeyUsage&^allowedKeyUsage != 0 { + return errResult("keyUsage asserts bits beyond digitalSignature (and keyEncipherment, for RSA keys)") + } + + // subjectAltName is "A sequence of 1 to 100 names of type dNSName or + // ipAddress (critical if CN omitted)". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1091 + sanExt := getExtension(c, subjectAltNameOID) + if sanExt == nil { + return errResult("subjectAltName extension is not present") + } + if len(c.OtherNames) != 0 || len(c.EmailAddresses) != 0 || len(c.DirectoryNames) != 0 || + len(c.EDIPartyNames) != 0 || len(c.URIs) != 0 || len(c.RegisteredIDs) != 0 { + return errResult("subjectAltName contains a name of a type other than dNSName or ipAddress") + } + totalNames := len(c.DNSNames) + len(c.IPAddresses) + if totalNames < 1 || totalNames > 100 { + return errResult("subjectAltName does not contain between 1 and 100 names") + } + if c.Subject.CommonName == "" && !sanExt.Critical { + return errResult("subjectAltName extension is not critical despite the subject commonName being omitted") + } + if c.Subject.CommonName != "" && sanExt.Critical { + return errResult("subjectAltName extension is critical despite the subject commonName being present") + } + + // subjectKeyIdentifier "Optionally contains a truncated hash of the + // subjectPublicKey, per Section 2(1) of RFC 7093". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1092 + skidExt := getExtension(c, subjectKeyIdentifierOID) + if skidExt != nil { + if skidExt.Critical { + return errResult("subjectKeyIdentifier extension is critical") + } + subjectPublicKey, err := stdx509.ParsePKIXPublicKey(c.RawSubjectPublicKeyInfo) + if err != nil { + return errResult("failed to parse subjectPublicKey") + } + // core.GenerateSKID implements the RFC 7093 Section 2(1) method. + expectedSKID, err := core.GenerateSKID(subjectPublicKey) + if err != nil { + return errResult("failed to compute subjectKeyIdentifier from the subjectPublicKey") + } + if !bytes.Equal(c.SubjectKeyId, expectedSKID) { + return errResult("subjectKeyIdentifier is not the RFC 7093 Section 2(1) truncated hash of the subjectPublicKey") + } + } + + // signatureAlgorithm is "Byte-for-byte identical to the + // tbsCertificate.signature". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1094 + signatureAlgorithm, err := getOuterSignatureAlgorithm(c.Raw) + if err != nil { + return errResult(err.Error()) + } + if !bytes.Equal(tbsSignature, signatureAlgorithm) { + return errResult("signatureAlgorithm is not byte-for-byte identical to the tbsCertificate.signature") + } + + // signatureValue is "A signature appropriate to the signatureAlgorithm + // field". We can't verify the signature here: pre-issuance linting + // operates on a certificate signed by a throwaway key. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1095 + + return nil +} diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go new file mode 100644 index 00000000000..9486324bfb5 --- /dev/null +++ b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go @@ -0,0 +1,292 @@ +package cpcps + +import ( + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "fmt" + "net" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" +) + +func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { + t.Parallel() + + intKey := testKey(t, elliptic.P384()) + intTmpl := testIntermediateTemplate(t, intKey.Public()) + intDER, err := x509.CreateCertificate(rand.Reader, intTmpl, intTmpl, intKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test issuer: %s", err) + } + + testCases := []struct { + name string + mod func(t *testing.T, tmpl *x509.Certificate) + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "good_cn_omitted", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject = pkix.Name{} + }, + want: lint.Pass, + }, + { + name: "good_ip_cn", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.CommonName = "10.1.2.3" + tmpl.DNSNames = nil + tmpl.IPAddresses = []net.IP{net.ParseIP("10.1.2.3")} + }, + want: lint.Pass, + }, + { + name: "good_with_skid", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // This mod relies on the leaf key being generated before the + // template is modified; see the test body. + }, + want: lint.Pass, + }, + { + name: "validity_too_long", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = tmpl.NotBefore.AddDate(0, 0, 101) + }, + want: lint.Error, + wantSubStr: "validity is more than 100 days", + }, + { + name: "serial_too_short", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.SerialNumber = testSerial(t, 16) + }, + want: lint.Error, + wantSubStr: "serialNumber is not approximately 144 bits", + }, + { + name: "cn_not_in_sans", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.CommonName = "other.example.org" + }, + want: lint.Error, + wantSubStr: "subject commonName is not one of the subjectAltName dNSName values", + }, + { + name: "ip_cn_not_in_sans", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.CommonName = "10.1.2.3" + }, + want: lint.Error, + wantSubStr: "subject commonName is not one of the subjectAltName ipAddress values", + }, + { + name: "extra_subject_attribute", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.Organization = []string{"Example"} + }, + want: lint.Error, + wantSubStr: "subject contains an attribute other than commonName", + }, + { + name: "email_san", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.EmailAddresses = []string{"admin@example.com"} + }, + want: lint.Error, + wantSubStr: "subjectAltName contains a name of a type other than dNSName or ipAddress", + }, + { + name: "too_many_sans", + mod: func(t *testing.T, tmpl *x509.Certificate) { + var names []string + for i := range 101 { + names = append(names, fmt.Sprintf("%d.example.com", i)) + } + tmpl.Subject = pkix.Name{} + tmpl.DNSNames = names + }, + want: lint.Error, + wantSubStr: "subjectAltName does not contain between 1 and 100 names", + }, + { + name: "no_sans", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject = pkix.Name{} + tmpl.DNSNames = nil + tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, pkix.Extension{ + // An empty, critical subjectAltName extension containing + // an empty GeneralNames SEQUENCE. + Id: asn1.ObjectIdentifier{2, 5, 29, 17}, + Critical: true, + Value: []byte{0x30, 0x00}, + }) + }, + want: lint.Error, + wantSubStr: "subjectAltName does not contain between 1 and 100 names", + }, + { + name: "extra_key_usage_bit", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.KeyUsage = x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment + }, + want: lint.Error, + wantSubStr: "keyUsage asserts bits beyond digitalSignature", + }, + { + name: "missing_digital_signature", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.KeyUsage = x509.KeyUsageKeyEncipherment + }, + want: lint.Error, + wantSubStr: "keyUsage does not assert digitalSignature", + }, + { + name: "extra_eku", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} + }, + want: lint.Error, + wantSubStr: "extKeyUsage does not contain exactly id-kp-serverAuth", + }, + { + name: "missing_policies", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Policies = nil + }, + want: lint.Error, + wantSubStr: "certificatePolicies extension is not present", + }, + { + name: "missing_aia", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = nil + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess extension is not present", + }, + { + name: "missing_crldp", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = nil + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints extension is not present", + }, + { + name: "one_sct", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtraExtensions = []pkix.Extension{ + testSCTListExtension(t, [32]byte{1}), + } + }, + want: lint.Error, + wantSubStr: "signedCertificateTimestampList does not contain SCTs from at least two distinct logs", + }, + { + name: "two_scts_same_log", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtraExtensions = []pkix.Extension{ + testSCTListExtension(t, [32]byte{1}, [32]byte{1}), + } + }, + want: lint.Error, + wantSubStr: "signedCertificateTimestampList does not contain SCTs from at least two distinct logs", + }, + { + name: "missing_scts", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtraExtensions = nil + }, + want: lint.Error, + wantSubStr: "signedCertificateTimestampList extension is not present", + }, + { + name: "wrong_skid", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.SubjectKeyId = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + }, + want: lint.Error, + wantSubStr: "subjectKeyIdentifier is not the RFC 7093", + }, + { + name: "extra_extension", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, pkix.Extension{ + Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 44947, 999}, Value: []byte{0x05, 0x00}, + }) + }, + want: lint.Error, + wantSubStr: "unexpected extension", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + leafKey := testKey(t, elliptic.P256()) + tmpl := testLeafTemplate(t) + if tc.name == "good_with_skid" { + tmpl.SubjectKeyId = testRFC7093SKID(t, leafKey.Public()) + } + if tc.mod != nil { + tc.mod(t, tmpl) + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, intTmpl, leafKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + cert := testParseZCert(t, der) + + l := &subscriberServerCertificateMatchesCPSProfile{Config: &SharedConfig{IssuerCertificatePEM: testPEM(t, intDER)}} + if !l.CheckApplies(cert) { + t.Fatal("lint does not apply to test certificate") + } + + res := l.Execute(cert) + if res.Status != tc.want { + t.Errorf("got status %s (%q), want %s", res.Status, res.Details, tc.want) + } + if !strings.Contains(res.Details, tc.wantSubStr) { + t.Errorf("got details %q, want substring %q", res.Details, tc.wantSubStr) + } + }) + } +} + +func TestSubscriberServerCertificateMatchesCPSProfileCheckApplies(t *testing.T) { + t.Parallel() + + intKey := testKey(t, elliptic.P384()) + intTmpl := testIntermediateTemplate(t, intKey.Public()) + + // A precertificate (containing the CT poison extension) is covered by the + // precertificate profile, not this one. + leafKey := testKey(t, elliptic.P256()) + tmpl := testLeafTemplate(t) + tmpl.ExtraExtensions = []pkix.Extension{ + {Id: testCTPoisonOID, Critical: true, Value: []byte{0x05, 0x00}}, + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, intTmpl, leafKey.Public(), intKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + + l := NewSubscriberServerCertificateMatchesCPSProfile() + if l.CheckApplies(testParseZCert(t, der)) { + t.Error("lint applies to precertificate") + } +} diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go new file mode 100644 index 00000000000..c5f5ff3b42f --- /dev/null +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -0,0 +1,351 @@ +package cpcps + +import ( + "bytes" + "crypto/elliptic" + stdx509 "crypto/x509" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/zmap/zcrypto/encoding/asn1" + zrsa "github.com/zmap/zcrypto/rsa" + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/core" + "github.com/letsencrypt/boulder/linter/lints" +) + +type tlsSubordinateCACertificateMatchesCPSProfile struct { + // Config is filled from the shared [Global] stanza of the lint + // configuration; see IssuingCAConfig. + Config *SharedConfig +} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_tls_subordinate_ca_certificate_matches_cps_profile", + Description: "Let's Encrypt TLS Subordinate CA Certificates are issued in accordance with the CP/CPS Profile", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV62Date, + }, + Lint: NewTLSSubordinateCACertificateMatchesCPSProfile, + }) +} + +func NewTLSSubordinateCACertificateMatchesCPSProfile() lint.CertificateLintInterface { + return &tlsSubordinateCACertificateMatchesCPSProfile{} +} + +// Configure implements the lint.Configurable interface. +func (l *tlsSubordinateCACertificateMatchesCPSProfile) Configure() any { + return l +} + +// isCrossCertified is a heuristic for distinguishing the two kinds of +// subordinate CA certificates that ISRG issues. A Cross-Certified Subordinate +// CA Certificate confers a second issuance path upon an existing CA, so its +// subject is byte-for-byte identical to that existing CA Certificate's +// subject; in practice the existing CA is always an ISRG root (subject +// O=ISRG), while TLS Subordinate CA Certificates are required to have subject +// O=Let's Encrypt. +func isCrossCertified(c *x509.Certificate) bool { + return len(c.Subject.Organization) == 1 && c.Subject.Organization[0] == "ISRG" +} + +func (l *tlsSubordinateCACertificateMatchesCPSProfile) CheckApplies(c *x509.Certificate) bool { + return util.IsSubCA(c) && !isCrossCertified(c) +} + +// Execute checks the given certificate against the TLS Subordinate CA +// Certificate Profile, row by row. +// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1041-L1066 +func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { + // Several rows of the profile require byte-for-byte correspondence with + // the Issuing CA's certificate, so this lint must be configured with it. + issuer, err := parseConfiguredCertificate(l.Config.issuerPEM()) + if err != nil { + return fatalResult(err.Error()) + } + if issuer == nil { + return fatalResult("lint has not been configured with the Issuing CA's certificate (issuer_certificate)") + } + + // version is "X.509 version 3" (Section 7.1.1). Note that unlike + // crypto/x509, zcrypto's Version field is one-indexed: it holds 3 (not the + // raw encoded value 2) for a v3 certificate. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1046 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1101-L1103 + if c.Version != 3 { + return errResult("version is not v3") + } + + // serialNumber is "Approximately 128 bits, including at least 64 bits of + // output from a CSPRNG". We can't test randomness here, but a 128-bit + // serial occupies exactly 16 bytes. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1047 + if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { + return errResult("serialNumber is not a positive integer") + } + if (c.SerialNumber.BitLen()+7)/8 != 16 { + return errResult("serialNumber is not approximately 128 bits") + } + + // signature is byte-for-byte identical to one of the hexadecimal encodings + // specified by Section 7.1.3.2 of the Baseline Requirements (Section + // 7.1.3.2). + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1048 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + tbsSignature, err := util.GetSignatureAlgorithmInTBSEncoded(c) + if err != nil { + return errResult("failed to parse tbsCertificate.signature") + } + if !brSignatureAlgorithmIdentifiers[hex.EncodeToString(tbsSignature)] { + return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") + } + + // issuer is "Byte-for-byte identical to the subject field of the Issuing + // CA". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1049 + if !bytes.Equal(c.RawIssuer, issuer.RawSubject) { + return errResult("issuer is not byte-for-byte identical to the subject of the configured Issuing CA") + } + + // validity is "At most 8 years". RFC 5280 4.1.2.5: "The validity period + // for a certificate is the period of time from notBefore through notAfter, + // inclusive." + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1050 + if c.NotAfter.Add(time.Second).After(c.NotBefore.AddDate(8, 0, 0)) { + return errResult("validity is more than 8 years") + } + + // subject is "C=US, O=Let's Encrypt, and a unique CN". + // We can't test for CN uniqueness here, but the rest we can check. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1051 + if len(c.Subject.Names) != 3 { + return errResult("subject does not contain exactly C, O, and CN attributes") + } + if len(c.Subject.Country) != 1 || c.Subject.Country[0] != "US" { + return errResult("subject countryName is not US") + } + if len(c.Subject.Organization) != 1 || c.Subject.Organization[0] != "Let's Encrypt" { + return errResult("subject organizationName is not Let's Encrypt") + } + if c.Subject.CommonName == "" { + return errResult("subject commonName is empty") + } + + // subjectPublicKeyInfo: Section 6.1.5 says Subordinate CA key pairs are + // "either RSA keys whose encoded modulus size is 2048 bits, or ECDSA keys + // which are a valid point on the NIST P-384 elliptic curve", and Section + // 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte identical + // to a BRs Section 7.1.3.1 encoding. Point-on-curve validation is + // performed by zcrypto at parse time. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1052 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L854 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + spkiAlgID, err := util.GetPublicKeyAidEncoded(c) + if err != nil { + return errResult("failed to parse subjectPublicKeyInfo algorithm") + } + switch key := c.PublicKey.(type) { + case *zrsa.PublicKey: + if key.N.BitLen() != 2048 { + return errResult(fmt.Sprintf("RSA modulus size %d is not allowed", key.N.BitLen())) + } + if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { + return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") + } + case *x509.AugmentedECDSA: + if key.Pub.Curve != elliptic.P384() { + return errResult(fmt.Sprintf("ECDSA curve %s is not allowed", key.Pub.Curve.Params().Name)) + } + if hex.EncodeToString(spkiAlgID) != spkiAlgorithmP384 { + return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 encoding for its curve") + } + default: + return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) + } + + // issuerUniqueID is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1053 + if c.IssuerUniqueId.BitLength != 0 || len(c.IssuerUniqueId.Bytes) != 0 { + return errResult("issuerUniqueID is present") + } + + // subjectUniqueID is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1054 + if c.SubjectUniqueId.BitLength != 0 || len(c.SubjectUniqueId.Bytes) != 0 { + return errResult("subjectUniqueID is present") + } + + // authorityInformationAccess "Contains the HTTP URI of the Issuing CA's + // Certificate". Whether the URI actually serves the Issuing CA's + // certificate is not observable here, but the extension must contain + // exactly one caIssuers entry with an http URI and nothing else (in + // particular, no OCSP entries). + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1056 + if !util.IsExtInCert(c, authorityInformationAccessOID) { + return errResult("authorityInformationAccess extension is not present") + } + if len(c.OCSPServer) != 0 { + return errResult("authorityInformationAccess contains an OCSP entry") + } + if len(c.IssuingCertificateURL) != 1 { + return errResult("authorityInformationAccess does not contain exactly one caIssuers entry") + } + if !strings.HasPrefix(c.IssuingCertificateURL[0], "http://") { + return errResult("authorityInformationAccess caIssuers URI does not use the http scheme") + } + + // authorityKeyIdentifier "Contains a keyIdentifier byte-for-byte identical + // to the subjectKeyIdentifier of the Issuing CA". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1057 + akidExt := getExtension(c, authorityKeyIdentifierOID) + if akidExt == nil { + return errResult("authorityKeyIdentifier extension is not present") + } + if akidExt.Critical { + return errResult("authorityKeyIdentifier extension is critical") + } + if len(c.AuthorityKeyId) == 0 { + return errResult("authorityKeyIdentifier does not contain a keyIdentifier") + } + if !bytes.Equal(c.AuthorityKeyId, issuer.SubjectKeyId) { + return errResult("authorityKeyIdentifier keyIdentifier is not byte-for-byte identical to the subjectKeyIdentifier of the configured Issuing CA") + } + + // basicConstraints is "Critical, with cA set to true and pathLenConstraint + // set to 0". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1058 + bcExt := getExtension(c, basicConstraintsOID) + if bcExt == nil { + return errResult("basicConstraints extension is not present") + } + if !bcExt.Critical { + return errResult("basicConstraints extension is not critical") + } + if !c.IsCA { + return errResult("basicConstraints cA is not true") + } + if c.MaxPathLen != 0 || !c.MaxPathLenZero { + return errResult("basicConstraints pathLenConstraint is not 0") + } + + // certificatePolicies "Contains only the Baseline Requirements Domain + // Validated Reserved Policy Identifier (OID 2.23.140.1.2.1)". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1059 + if !util.IsExtInCert(c, certificatePoliciesOID) { + return errResult("certificatePolicies extension is not present") + } + if len(c.PolicyIdentifiers) != 1 || !c.PolicyIdentifiers[0].Equal(domainValidatedOID) { + return errResult("certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier") + } + + // crlDistributionPoints "Contains the HTTP URI of a CRL issued by the + // Issuing CA whose scope includes this certificate". Whether the CRL's + // scope actually includes this certificate is not observable here. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1060 + if !util.IsExtInCert(c, crlDistributionPointsOID) { + return errResult("crlDistributionPoints extension is not present") + } + if len(c.CRLDistributionPoints) != 1 { + return errResult("crlDistributionPoints does not contain exactly one distribution point") + } + if !strings.HasPrefix(c.CRLDistributionPoints[0], "http://") { + return errResult("crlDistributionPoints URI does not use the http scheme") + } + + // extKeyUsage "Contains only id-kp-serverAuth (OID 1.3.6.1.5.5.7.3.1)". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1061 + if !util.IsExtInCert(c, extKeyUsageOID) { + return errResult("extKeyUsage extension is not present") + } + if len(c.ExtKeyUsage) != 1 || c.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth || len(c.UnknownExtKeyUsage) != 0 { + return errResult("extKeyUsage does not contain exactly id-kp-serverAuth") + } + + // keyUsage is "Critical, with only the digitalSignature (0), keyCertSign + // (5), and cRLSign (6) bits set". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1062 + kuExt := getExtension(c, keyUsageOID) + if kuExt == nil { + return errResult("keyUsage extension is not present") + } + if !kuExt.Critical { + return errResult("keyUsage extension is not critical") + } + if c.KeyUsage != x509.KeyUsageDigitalSignature|x509.KeyUsageCertSign|x509.KeyUsageCRLSign { + return errResult("keyUsage does not assert exactly the bits required by the profile") + } + + // subjectKeyIdentifier "Contains a truncated hash of the subjectPublicKey, + // per Section 2(1) of RFC 7093". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1063 + skidExt := getExtension(c, subjectKeyIdentifierOID) + if skidExt == nil { + return errResult("subjectKeyIdentifier extension is not present") + } + if skidExt.Critical { + return errResult("subjectKeyIdentifier extension is critical") + } + subjectPublicKey, err := stdx509.ParsePKIXPublicKey(c.RawSubjectPublicKeyInfo) + if err != nil { + return errResult("failed to parse subjectPublicKey") + } + // core.GenerateSKID implements the RFC 7093 Section 2(1) method. + expectedSKID, err := core.GenerateSKID(subjectPublicKey) + if err != nil { + return errResult("failed to compute subjectKeyIdentifier from the subjectPublicKey") + } + if !bytes.Equal(c.SubjectKeyId, expectedSKID) { + return errResult("subjectKeyIdentifier is not the RFC 7093 Section 2(1) truncated hash of the subjectPublicKey") + } + + // Any other extension is "Not present". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1064 + allowedExtensions := []asn1.ObjectIdentifier{ + authorityInformationAccessOID, + authorityKeyIdentifierOID, + basicConstraintsOID, + certificatePoliciesOID, + crlDistributionPointsOID, + extKeyUsageOID, + keyUsageOID, + subjectKeyIdentifierOID, + } + for _, ext := range c.Extensions { + found := false + for _, oid := range allowedExtensions { + if ext.Id.Equal(oid) { + found = true + } + } + if !found { + return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) + } + } + + // signatureAlgorithm is "Byte-for-byte identical to the + // tbsCertificate.signature". + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1065 + signatureAlgorithm, err := getOuterSignatureAlgorithm(c.Raw) + if err != nil { + return errResult(err.Error()) + } + if !bytes.Equal(tbsSignature, signatureAlgorithm) { + return errResult("signatureAlgorithm is not byte-for-byte identical to the tbsCertificate.signature") + } + + // signatureValue is "A signature appropriate to the signatureAlgorithm + // field". We can't verify the signature here: pre-issuance linting + // operates on a certificate signed by a throwaway key. + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1066 + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go new file mode 100644 index 00000000000..93ad3021841 --- /dev/null +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go @@ -0,0 +1,229 @@ +package cpcps + +import ( + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "strings" + "testing" + + "github.com/zmap/zlint/v3/lint" +) + +func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { + t.Parallel() + + rootKey := testKey(t, elliptic.P384()) + rootTmpl := testRootTemplate(t, rootKey.Public()) + rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, rootKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test issuer: %s", err) + } + + testCases := []struct { + name string + curve elliptic.Curve + mod func(t *testing.T, tmpl *x509.Certificate) + want lint.LintStatus + wantSubStr string + }{ + { + name: "good", + want: lint.Pass, + }, + { + name: "validity_too_long", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = tmpl.NotBefore.AddDate(9, 0, 0) + }, + want: lint.Error, + wantSubStr: "validity is more than 8 years", + }, + { + name: "serial_too_long", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.SerialNumber = testSerial(t, 18) + }, + want: lint.Error, + wantSubStr: "serialNumber is not approximately 128 bits", + }, + { + name: "wrong_organization", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.Organization = []string{"LE Incorporated"} + }, + want: lint.Error, + wantSubStr: "subject organizationName is not Let's Encrypt", + }, + { + name: "extra_subject_attribute", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.OrganizationalUnit = []string{"Intermediates"} + }, + want: lint.Error, + wantSubStr: "subject does not contain exactly C, O, and CN attributes", + }, + { + name: "missing_common_name", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.Subject.CommonName = "" + }, + want: lint.Error, + wantSubStr: "subject does not contain exactly C, O, and CN attributes", + }, + { + name: "wrong_curve", + curve: elliptic.P521(), + want: lint.Error, + wantSubStr: "ECDSA curve P-521 is not allowed", + }, + { + name: "missing_pathlen", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.MaxPathLen = -1 + tmpl.MaxPathLenZero = false + }, + want: lint.Error, + wantSubStr: "basicConstraints pathLenConstraint is not 0", + }, + { + name: "extra_eku", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth} + }, + want: lint.Error, + wantSubStr: "extKeyUsage does not contain exactly id-kp-serverAuth", + }, + { + name: "missing_digital_signature", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.KeyUsage = x509.KeyUsageCertSign | x509.KeyUsageCRLSign + }, + want: lint.Error, + wantSubStr: "keyUsage does not assert exactly the bits required by the profile", + }, + { + name: "wrong_policy", + mod: func(t *testing.T, tmpl *x509.Certificate) { + ovOID, err := x509.OIDFromInts([]uint64{2, 23, 140, 1, 2, 2}) + if err != nil { + t.Fatalf("creating OID: %s", err) + } + tmpl.Policies = []x509.OID{ovOID} + }, + want: lint.Error, + wantSubStr: "certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier", + }, + { + name: "missing_crldp", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = nil + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints extension is not present", + }, + { + name: "https_aia", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"https://x99.i.lencr.org/"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI does not use the http scheme", + }, + { + name: "aia_contains_ocsp", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.OCSPServer = []string{"http://ocsp.x99.lencr.org/"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess contains an OCSP entry", + }, + { + name: "wrong_skid", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.SubjectKeyId = []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20} + }, + want: lint.Error, + wantSubStr: "subjectKeyIdentifier is not the RFC 7093", + }, + { + name: "extra_extension", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.ExtraExtensions = []pkix.Extension{ + {Id: asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 44947, 999}, Value: []byte{0x05, 0x00}}, + } + }, + want: lint.Error, + wantSubStr: "unexpected extension", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + curve := tc.curve + if curve == nil { + curve = elliptic.P384() + } + intKey := testKey(t, curve) + + tmpl := testIntermediateTemplate(t, intKey.Public()) + if tc.mod != nil { + tc.mod(t, tmpl) + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, rootTmpl, intKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + cert := testParseZCert(t, der) + + l := &tlsSubordinateCACertificateMatchesCPSProfile{Config: &SharedConfig{IssuerCertificatePEM: testPEM(t, rootDER)}} + if !l.CheckApplies(cert) { + t.Fatal("lint does not apply to test certificate") + } + + res := l.Execute(cert) + if res.Status != tc.want { + t.Errorf("got status %s (%q), want %s", res.Status, res.Details, tc.want) + } + if !strings.Contains(res.Details, tc.wantSubStr) { + t.Errorf("got details %q, want substring %q", res.Details, tc.wantSubStr) + } + }) + } +} + +func TestTLSSubordinateCACertificateMatchesCPSProfileCheckApplies(t *testing.T) { + t.Parallel() + + rootKey := testKey(t, elliptic.P384()) + rootTmpl := testRootTemplate(t, rootKey.Public()) + + l := NewTLSSubordinateCACertificateMatchesCPSProfile() + + // A cross-certified subordinate CA certificate (subject O=ISRG) is covered + // by the cross-certified profile, not this one. + crossKey := testKey(t, elliptic.P384()) + crossTmpl := testIntermediateTemplate(t, crossKey.Public()) + crossTmpl.Subject.Organization = []string{"ISRG"} + crossDER, err := x509.CreateCertificate(rand.Reader, crossTmpl, rootTmpl, crossKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + if l.CheckApplies(testParseZCert(t, crossDER)) { + t.Error("lint applies to cross-certified (subject O=ISRG) certificate") + } + + // A self-signed root is covered by the root profile, not this one. + rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, rootKey.Public(), rootKey) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + if l.CheckApplies(testParseZCert(t, rootDER)) { + t.Error("lint applies to self-signed root certificate") + } +} From 2b658dab726195aa9bbd099eef8700386c5eb3d7 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 21 Jul 2026 17:22:52 -0700 Subject: [PATCH 04/18] Add SKID test vector --- core/util_test.go | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/core/util_test.go b/core/util_test.go index 6de8c3aec6e..77ef25e4dab 100644 --- a/core/util_test.go +++ b/core/util_test.go @@ -5,6 +5,8 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/x509" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -122,13 +124,24 @@ func TestKeyDigestEquals(t *testing.T) { func TestGenerateSKID(t *testing.T) { t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - test.AssertNotError(t, err, "Error generating key") - sha256skid, err := GenerateSKID(key.Public()) + // Test basics with a random key. + key1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Error generating key") + skid, err := GenerateSKID(key1.Public()) + test.AssertNotError(t, err, "Error generating SKID") + test.AssertEquals(t, len(skid), 20) + test.AssertEquals(t, cap(skid), 20) + + // Test specific output with the known test vector from RFC 7093 Section 3: + spkiDER, err := hex.DecodeString( + "3059301306072a8648ce3d020106082a8648ce3d030107034200047f7f35a79794c950060b8029fc8f363a28f11159692d9d34e6ac948190434735f833b1a66652dc514337aff7f5c9c75d670c019d95a5d639b72744c64a9128bb") + test.AssertNotError(t, err, "Error decoding test vector SPKI") + key2, err := x509.ParsePKIXPublicKey(spkiDER) + test.AssertNotError(t, err, "Error parsing test vector SPKI") + skid, err = GenerateSKID(key2) test.AssertNotError(t, err, "Error generating SKID") - test.AssertEquals(t, len(sha256skid), 20) - test.AssertEquals(t, cap(sha256skid), 20) + test.AssertEquals(t, hex.EncodeToString(skid), "bf37b3e5808fd46d54b28e846311bcce1cad2e1a") } func TestIsAnyNilOrZero(t *testing.T) { From 333527dccd84e62239d63ef75daab964aeaff189 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 21 Jul 2026 17:23:01 -0700 Subject: [PATCH 05/18] Update validity periods --- linter/lints/cpcps/helpers_test.go | 9 +++++---- .../lint_cross_certified_subordinate_ca_certificate.go | 10 +++++----- ..._cross_certified_subordinate_ca_certificate_test.go | 8 +++++--- linter/lints/cpcps/lint_root_ca_certificate.go | 10 +++++----- linter/lints/cpcps/lint_root_ca_certificate_test.go | 3 ++- .../lints/cpcps/lint_tls_subordinate_ca_certificate.go | 10 +++++----- .../cpcps/lint_tls_subordinate_ca_certificate_test.go | 5 +++-- 7 files changed, 30 insertions(+), 25 deletions(-) diff --git a/linter/lints/cpcps/helpers_test.go b/linter/lints/cpcps/helpers_test.go index 4af2cd2c3f6..663c9543e34 100644 --- a/linter/lints/cpcps/helpers_test.go +++ b/linter/lints/cpcps/helpers_test.go @@ -110,8 +110,8 @@ func testRootTemplate(t *testing.T, pub crypto.PublicKey) *x509.Certificate { CommonName: "ISRG Root X99", }, NotBefore: notBefore, - // 9132 days, inclusive of the final second. - NotAfter: notBefore.AddDate(0, 0, 9132).Add(-time.Second), + // 3660 days, inclusive of the final second. + NotAfter: notBefore.AddDate(0, 0, 3660).Add(-time.Second), BasicConstraintsValid: true, IsCA: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, @@ -131,8 +131,9 @@ func testIntermediateTemplate(t *testing.T, pub crypto.PublicKey) *x509.Certific Organization: []string{"Let's Encrypt"}, CommonName: "E99", }, - NotBefore: notBefore, - NotAfter: notBefore.AddDate(5, 0, 0).Add(-time.Second), + NotBefore: notBefore, + // 1098 days, inclusive of the final second. + NotAfter: notBefore.AddDate(0, 0, 1098).Add(-time.Second), BasicConstraintsValid: true, IsCA: true, MaxPathLen: 0, diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go index 189acaca466..949db06a98a 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -111,12 +111,12 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("issuer is not byte-for-byte identical to the subject of the configured Issuing CA") } - // validity is "At most 8 years". RFC 5280 4.1.2.5: "The validity period - // for a certificate is the period of time from notBefore through notAfter, - // inclusive." + // validity is "At most 1098 days (approx. 3 years)". RFC 5280 4.1.2.5: + // "The validity period for a certificate is the period of time from + // notBefore through notAfter, inclusive." // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1023 - if c.NotAfter.Add(time.Second).After(c.NotBefore.AddDate(8, 0, 0)) { - return errResult("validity is more than 8 years") + if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 1098*lints.BRDay { + return errResult("validity is more than 1098 days") } // subject is "Byte-for-byte identical to the subject field of the existing diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go index d4c91e7236a..4cda897307a 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go @@ -27,7 +27,8 @@ func testCrossCertTemplate(t *testing.T, pub crypto.PublicKey) *x509.Certificate CommonName: "ISRG Root X100", }, NotBefore: notBefore, - NotAfter: notBefore.AddDate(5, 0, 0).Add(-time.Second), + // 1098 days, inclusive of the final second. + NotAfter: notBefore.AddDate(0, 0, 1098).Add(-time.Second), // The existing CA Certificate being cross-signed is a root, which has // no pathLenConstraint, so MaxPathLen and MaxPathLenZero are left at // their zero values. @@ -77,10 +78,11 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { { name: "validity_too_long", mod: func(t *testing.T, tmpl *x509.Certificate) { - tmpl.NotAfter = tmpl.NotBefore.AddDate(9, 0, 0) + // One second past the maximum 1098-day validity period. + tmpl.NotAfter = tmpl.NotBefore.AddDate(0, 0, 1098) }, want: lint.Error, - wantSubStr: "validity is more than 8 years", + wantSubStr: "validity is more than 1098 days", }, { name: "extra_key_usage_bit", diff --git a/linter/lints/cpcps/lint_root_ca_certificate.go b/linter/lints/cpcps/lint_root_ca_certificate.go index 5479028b974..a6008c86479 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate.go +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -86,12 +86,12 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("issuer is not byte-for-byte identical to the subject") } - // validity is "At most 9132 days". RFC 5280 4.1.2.5: "The validity period - // for a certificate is the period of time from notBefore through notAfter, - // inclusive." + // validity is "At most 3660 days (approx. 10 years)". RFC 5280 4.1.2.5: + // "The validity period for a certificate is the period of time from + // notBefore through notAfter, inclusive." // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1001 - if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 9132*lints.BRDay { - return errResult("validity is more than 9132 days") + if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 3660*lints.BRDay { + return errResult("validity is more than 3660 days") } // subject is "C=US, O=ISRG, and a unique CN". diff --git a/linter/lints/cpcps/lint_root_ca_certificate_test.go b/linter/lints/cpcps/lint_root_ca_certificate_test.go index 9b0b4c17d22..20c9f6e8ae6 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_root_ca_certificate_test.go @@ -31,6 +31,7 @@ func TestRootCACertificateMatchesCPSProfile(t *testing.T) { { name: "good_notafter_before_2050", mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotBefore = time.Date(2039, time.June, 1, 0, 0, 0, 0, time.UTC) tmpl.NotAfter = time.Date(2049, time.June, 1, 0, 0, 0, 0, time.UTC) }, want: lint.Pass, @@ -49,7 +50,7 @@ func TestRootCACertificateMatchesCPSProfile(t *testing.T) { tmpl.NotAfter = tmpl.NotAfter.Add(24 * time.Hour) }, want: lint.Error, - wantSubStr: "validity is more than 9132 days", + wantSubStr: "validity is more than 3660 days", }, { name: "wrong_organization", diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go index c5f5ff3b42f..b6444702a08 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -116,12 +116,12 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("issuer is not byte-for-byte identical to the subject of the configured Issuing CA") } - // validity is "At most 8 years". RFC 5280 4.1.2.5: "The validity period - // for a certificate is the period of time from notBefore through notAfter, - // inclusive." + // validity is "At most 1098 days (approx. 3 years)". RFC 5280 4.1.2.5: + // "The validity period for a certificate is the period of time from + // notBefore through notAfter, inclusive." // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1050 - if c.NotAfter.Add(time.Second).After(c.NotBefore.AddDate(8, 0, 0)) { - return errResult("validity is more than 8 years") + if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 1098*lints.BRDay { + return errResult("validity is more than 1098 days") } // subject is "C=US, O=Let's Encrypt, and a unique CN". diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go index 93ad3021841..507d8f15c99 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go @@ -36,10 +36,11 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { { name: "validity_too_long", mod: func(t *testing.T, tmpl *x509.Certificate) { - tmpl.NotAfter = tmpl.NotBefore.AddDate(9, 0, 0) + // One second past the maximum 1098-day validity period. + tmpl.NotAfter = tmpl.NotBefore.AddDate(0, 0, 1098) }, want: lint.Error, - wantSubStr: "validity is more than 8 years", + wantSubStr: "validity is more than 1098 days", }, { name: "serial_too_long", From a0d755a2357f7aed89b43e7d196b380b506f887c Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 21 Jul 2026 17:31:58 -0700 Subject: [PATCH 06/18] Simplify Issuer/SubjectUniqueId checks --- .../cpcps/lint_cross_certified_subordinate_ca_certificate.go | 4 ++-- linter/lints/cpcps/lint_root_ca_certificate.go | 4 ++-- linter/lints/cpcps/lint_subscriber_server_certificate.go | 4 ++-- linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go index 949db06a98a..57aa8c309d6 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -171,13 +171,13 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // issuerUniqueID is "Not present". // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1026 - if c.IssuerUniqueId.BitLength != 0 || len(c.IssuerUniqueId.Bytes) != 0 { + if c.IssuerUniqueId.Bytes != nil { return errResult("issuerUniqueID is present") } // subjectUniqueID is "Not present". // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1027 - if c.SubjectUniqueId.BitLength != 0 || len(c.SubjectUniqueId.Bytes) != 0 { + if c.SubjectUniqueId.Bytes != nil { return errResult("subjectUniqueID is present") } diff --git a/linter/lints/cpcps/lint_root_ca_certificate.go b/linter/lints/cpcps/lint_root_ca_certificate.go index a6008c86479..cc18b4ce1bb 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate.go +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -144,13 +144,13 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. // issuerUniqueID is "Not present". // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1004 - if c.IssuerUniqueId.BitLength != 0 || len(c.IssuerUniqueId.Bytes) != 0 { + if c.IssuerUniqueId.Bytes != nil { return errResult("issuerUniqueID is present") } // subjectUniqueID is "Not present". // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1005 - if c.SubjectUniqueId.BitLength != 0 || len(c.SubjectUniqueId.Bytes) != 0 { + if c.SubjectUniqueId.Bytes != nil { return errResult("subjectUniqueID is present") } diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate.go b/linter/lints/cpcps/lint_subscriber_server_certificate.go index 86b9104f451..acaae15d53a 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -249,13 +249,13 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // issuerUniqueID is "Not present". // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1080 - if c.IssuerUniqueId.BitLength != 0 || len(c.IssuerUniqueId.Bytes) != 0 { + if c.IssuerUniqueId.Bytes != nil { return errResult("issuerUniqueID is present") } // subjectUniqueID is "Not present". // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1081 - if c.SubjectUniqueId.BitLength != 0 || len(c.SubjectUniqueId.Bytes) != 0 { + if c.SubjectUniqueId.Bytes != nil { return errResult("subjectUniqueID is present") } diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go index b6444702a08..3fd428498c8 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -174,13 +174,13 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // issuerUniqueID is "Not present". // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1053 - if c.IssuerUniqueId.BitLength != 0 || len(c.IssuerUniqueId.Bytes) != 0 { + if c.IssuerUniqueId.Bytes != nil { return errResult("issuerUniqueID is present") } // subjectUniqueID is "Not present". // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1054 - if c.SubjectUniqueId.BitLength != 0 || len(c.SubjectUniqueId.Bytes) != 0 { + if c.SubjectUniqueId.Bytes != nil { return errResult("subjectUniqueID is present") } From a58b193a24b49320638b62be0af2ec35a06ed572 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 21 Jul 2026 17:41:19 -0700 Subject: [PATCH 07/18] Add negative validity checks --- .../lint_cross_certified_subordinate_ca_certificate.go | 3 +++ ...nt_cross_certified_subordinate_ca_certificate_test.go | 8 ++++++++ linter/lints/cpcps/lint_root_ca_certificate.go | 3 +++ linter/lints/cpcps/lint_root_ca_certificate_test.go | 8 ++++++++ linter/lints/cpcps/lint_subscriber_server_certificate.go | 3 +++ .../cpcps/lint_subscriber_server_certificate_test.go | 9 +++++++++ .../lints/cpcps/lint_tls_subordinate_ca_certificate.go | 3 +++ .../cpcps/lint_tls_subordinate_ca_certificate_test.go | 9 +++++++++ 8 files changed, 46 insertions(+) diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go index 57aa8c309d6..520547d6896 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -115,6 +115,9 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // "The validity period for a certificate is the period of time from // notBefore through notAfter, inclusive." // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1023 + if c.NotAfter.Before(c.NotBefore) { + return errResult("validity is negative: notAfter is before notBefore") + } if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 1098*lints.BRDay { return errResult("validity is more than 1098 days") } diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go index 4cda897307a..1776af0a3f0 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go @@ -84,6 +84,14 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "validity is more than 1098 days", }, + { + name: "validity_negative", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = tmpl.NotBefore.Add(-time.Second) + }, + want: lint.Error, + wantSubStr: "validity is negative", + }, { name: "extra_key_usage_bit", mod: func(t *testing.T, tmpl *x509.Certificate) { diff --git a/linter/lints/cpcps/lint_root_ca_certificate.go b/linter/lints/cpcps/lint_root_ca_certificate.go index cc18b4ce1bb..d1842449233 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate.go +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -90,6 +90,9 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. // "The validity period for a certificate is the period of time from // notBefore through notAfter, inclusive." // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1001 + if c.NotAfter.Before(c.NotBefore) { + return errResult("validity is negative: notAfter is before notBefore") + } if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 3660*lints.BRDay { return errResult("validity is more than 3660 days") } diff --git a/linter/lints/cpcps/lint_root_ca_certificate_test.go b/linter/lints/cpcps/lint_root_ca_certificate_test.go index 20c9f6e8ae6..902b549bd84 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_root_ca_certificate_test.go @@ -52,6 +52,14 @@ func TestRootCACertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "validity is more than 3660 days", }, + { + name: "validity_negative", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = tmpl.NotBefore.Add(-time.Second) + }, + want: lint.Error, + wantSubStr: "validity is negative", + }, { name: "wrong_organization", mod: func(t *testing.T, tmpl *x509.Certificate) { diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate.go b/linter/lints/cpcps/lint_subscriber_server_certificate.go index acaae15d53a..4a033d9625d 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -175,6 +175,9 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // for a certificate is the period of time from notBefore through notAfter, // inclusive." // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1077 + if c.NotAfter.Before(c.NotBefore) { + return errResult("validity is negative: notAfter is before notBefore") + } if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 100*lints.BRDay { return errResult("validity is more than 100 days") } diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go index 9486324bfb5..6557ab3bd7f 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go @@ -10,6 +10,7 @@ import ( "net" "strings" "testing" + "time" "github.com/zmap/zlint/v3/lint" ) @@ -66,6 +67,14 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "validity is more than 100 days", }, + { + name: "validity_negative", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = tmpl.NotBefore.Add(-time.Second) + }, + want: lint.Error, + wantSubStr: "validity is negative", + }, { name: "serial_too_short", mod: func(t *testing.T, tmpl *x509.Certificate) { diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go index 3fd428498c8..a8c85505e70 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -120,6 +120,9 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // "The validity period for a certificate is the period of time from // notBefore through notAfter, inclusive." // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1050 + if c.NotAfter.Before(c.NotBefore) { + return errResult("validity is negative: notAfter is before notBefore") + } if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 1098*lints.BRDay { return errResult("validity is more than 1098 days") } diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go index 507d8f15c99..0cf460b42f8 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go @@ -8,6 +8,7 @@ import ( "encoding/asn1" "strings" "testing" + "time" "github.com/zmap/zlint/v3/lint" ) @@ -42,6 +43,14 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "validity is more than 1098 days", }, + { + name: "validity_negative", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.NotAfter = tmpl.NotBefore.Add(-time.Second) + }, + want: lint.Error, + wantSubStr: "validity is negative", + }, { name: "serial_too_long", mod: func(t *testing.T, tmpl *x509.Certificate) { From c92235393174b0ba84c41c15d92ccf02f853d8e2 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 21 Jul 2026 18:52:08 -0700 Subject: [PATCH 08/18] Add tighter checks for http URIs --- ...ss_certified_subordinate_ca_certificate.go | 21 ++++-- ...rtified_subordinate_ca_certificate_test.go | 64 +++++++++++++++++++ .../lint_subscriber_server_certificate.go | 21 ++++-- ...lint_subscriber_server_certificate_test.go | 64 +++++++++++++++++++ .../lint_tls_subordinate_ca_certificate.go | 21 ++++-- ...int_tls_subordinate_ca_certificate_test.go | 58 ++++++++++++++++- 6 files changed, 233 insertions(+), 16 deletions(-) diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go index 520547d6896..054ee0547b2 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -5,9 +5,10 @@ import ( "crypto/elliptic" "encoding/hex" "fmt" - "strings" + "net/url" "time" + "github.com/weppos/publicsuffix-go/publicsuffix" "github.com/zmap/zcrypto/encoding/asn1" zrsa "github.com/zmap/zcrypto/rsa" "github.com/zmap/zcrypto/x509" @@ -199,8 +200,13 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 if len(c.IssuingCertificateURL) != 1 { return errResult("authorityInformationAccess does not contain exactly one caIssuers entry") } - if !strings.HasPrefix(c.IssuingCertificateURL[0], "http://") { - return errResult("authorityInformationAccess caIssuers URI does not use the http scheme") + aiaURL, err := url.Parse(c.IssuingCertificateURL[0]) + if err != nil || aiaURL.Scheme != "http" { + return errResult("authorityInformationAccess caIssuers URI is not an http URL") + } + _, err = publicsuffix.ParseFromListWithOptions(publicsuffix.DefaultList, aiaURL.Hostname(), &publicsuffix.FindOptions{IgnorePrivate: true, DefaultRule: nil}) + if err != nil { + return errResult("authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix") } // authorityKeyIdentifier "Contains a keyIdentifier byte-for-byte identical @@ -257,8 +263,13 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 if len(c.CRLDistributionPoints) != 1 { return errResult("crlDistributionPoints does not contain exactly one distribution point") } - if !strings.HasPrefix(c.CRLDistributionPoints[0], "http://") { - return errResult("crlDistributionPoints URI does not use the http scheme") + crldpURL, err := url.Parse(c.CRLDistributionPoints[0]) + if err != nil || crldpURL.Scheme != "http" { + return errResult("crlDistributionPoints URI is not an http URL") + } + _, err = publicsuffix.ParseFromListWithOptions(publicsuffix.DefaultList, crldpURL.Hostname(), &publicsuffix.FindOptions{IgnorePrivate: true, DefaultRule: nil}) + if err != nil { + return errResult("crlDistributionPoints URI hostname is not a domain under a public suffix") } // extKeyUsage "Contains only id-kp-serverAuth (OID 1.3.6.1.5.5.7.3.1)". diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go index 1776af0a3f0..bed8e92b909 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go @@ -124,6 +124,70 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "authorityInformationAccess extension is not present", }, + { + name: "https_aia", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"https://x99.i.lencr.org/"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI is not an http URL", + }, + { + name: "aia_unparseable", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"http://x99.i.lencr.org/%zz"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI is not an http URL", + }, + { + name: "aia_no_hostname", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"http:///x99.crt"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix", + }, + { + name: "aia_bad_tld", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"http://x99.i.lencr.invalid/"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix", + }, + { + name: "https_crldp", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"https://x99.c.lencr.org/1.crl"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI is not an http URL", + }, + { + name: "crldp_unparseable", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"http://x99.c.lencr.org/%zz"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI is not an http URL", + }, + { + name: "crldp_no_hostname", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"http:///1.crl"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI hostname is not a domain under a public suffix", + }, + { + name: "crldp_bad_tld", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"http://x99.c.lencr.invalid/1.crl"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI hostname is not a domain under a public suffix", + }, } for _, tc := range testCases { diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate.go b/linter/lints/cpcps/lint_subscriber_server_certificate.go index 4a033d9625d..05279aec242 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -7,10 +7,11 @@ import ( "encoding/hex" "fmt" "net" + "net/url" "slices" - "strings" "time" + "github.com/weppos/publicsuffix-go/publicsuffix" "github.com/zmap/zcrypto/encoding/asn1" zrsa "github.com/zmap/zcrypto/rsa" "github.com/zmap/zcrypto/x509" @@ -277,8 +278,13 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes if len(c.IssuingCertificateURL) != 1 { return errResult("authorityInformationAccess does not contain exactly one caIssuers entry") } - if !strings.HasPrefix(c.IssuingCertificateURL[0], "http://") { - return errResult("authorityInformationAccess caIssuers URI does not use the http scheme") + aiaURL, err := url.Parse(c.IssuingCertificateURL[0]) + if err != nil || aiaURL.Scheme != "http" { + return errResult("authorityInformationAccess caIssuers URI is not an http URL") + } + _, err = publicsuffix.ParseFromListWithOptions(publicsuffix.DefaultList, aiaURL.Hostname(), &publicsuffix.FindOptions{IgnorePrivate: true, DefaultRule: nil}) + if err != nil { + return errResult("authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix") } // authorityKeyIdentifier "Contains a keyIdentifier byte-for-byte identical @@ -331,8 +337,13 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes if len(c.CRLDistributionPoints) != 1 { return errResult("crlDistributionPoints does not contain exactly one distribution point") } - if !strings.HasPrefix(c.CRLDistributionPoints[0], "http://") { - return errResult("crlDistributionPoints URI does not use the http scheme") + crldpURL, err := url.Parse(c.CRLDistributionPoints[0]) + if err != nil || crldpURL.Scheme != "http" { + return errResult("crlDistributionPoints URI is not an http URL") + } + _, err = publicsuffix.ParseFromListWithOptions(publicsuffix.DefaultList, crldpURL.Hostname(), &publicsuffix.FindOptions{IgnorePrivate: true, DefaultRule: nil}) + if err != nil { + return errResult("crlDistributionPoints URI hostname is not a domain under a public suffix") } // extKeyUsage "Contains only id-kp-serverAuth (OID 1.3.6.1.5.5.7.3.1)". diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go index 6557ab3bd7f..7cb476e07cb 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go @@ -184,6 +184,70 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "authorityInformationAccess extension is not present", }, + { + name: "https_aia", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"https://e99.i.lencr.org/"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI is not an http URL", + }, + { + name: "aia_unparseable", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"http://e99.i.lencr.org/%zz"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI is not an http URL", + }, + { + name: "aia_no_hostname", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"http:///e99.crt"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix", + }, + { + name: "aia_bad_tld", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"http://e99.i.lencr.invalid/"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix", + }, + { + name: "https_crldp", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"https://e99.c.lencr.org/1.crl"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI is not an http URL", + }, + { + name: "crldp_unparseable", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"http://e99.c.lencr.org/%zz"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI is not an http URL", + }, + { + name: "crldp_no_hostname", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"http:///1.crl"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI hostname is not a domain under a public suffix", + }, + { + name: "crldp_bad_tld", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"http://e99.c.lencr.invalid/1.crl"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI hostname is not a domain under a public suffix", + }, { name: "missing_crldp", mod: func(t *testing.T, tmpl *x509.Certificate) { diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go index a8c85505e70..2d48149b3db 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -6,9 +6,10 @@ import ( stdx509 "crypto/x509" "encoding/hex" "fmt" - "strings" + "net/url" "time" + "github.com/weppos/publicsuffix-go/publicsuffix" "github.com/zmap/zcrypto/encoding/asn1" zrsa "github.com/zmap/zcrypto/rsa" "github.com/zmap/zcrypto/x509" @@ -202,8 +203,13 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica if len(c.IssuingCertificateURL) != 1 { return errResult("authorityInformationAccess does not contain exactly one caIssuers entry") } - if !strings.HasPrefix(c.IssuingCertificateURL[0], "http://") { - return errResult("authorityInformationAccess caIssuers URI does not use the http scheme") + aiaURL, err := url.Parse(c.IssuingCertificateURL[0]) + if err != nil || aiaURL.Scheme != "http" { + return errResult("authorityInformationAccess caIssuers URI is not an http URL") + } + _, err = publicsuffix.ParseFromListWithOptions(publicsuffix.DefaultList, aiaURL.Hostname(), &publicsuffix.FindOptions{IgnorePrivate: true, DefaultRule: nil}) + if err != nil { + return errResult("authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix") } // authorityKeyIdentifier "Contains a keyIdentifier byte-for-byte identical @@ -260,8 +266,13 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica if len(c.CRLDistributionPoints) != 1 { return errResult("crlDistributionPoints does not contain exactly one distribution point") } - if !strings.HasPrefix(c.CRLDistributionPoints[0], "http://") { - return errResult("crlDistributionPoints URI does not use the http scheme") + crldpURL, err := url.Parse(c.CRLDistributionPoints[0]) + if err != nil || crldpURL.Scheme != "http" { + return errResult("crlDistributionPoints URI is not an http URL") + } + _, err = publicsuffix.ParseFromListWithOptions(publicsuffix.DefaultList, crldpURL.Hostname(), &publicsuffix.FindOptions{IgnorePrivate: true, DefaultRule: nil}) + if err != nil { + return errResult("crlDistributionPoints URI hostname is not a domain under a public suffix") } // extKeyUsage "Contains only id-kp-serverAuth (OID 1.3.6.1.5.5.7.3.1)". diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go index 0cf460b42f8..d9dfc2ecb6b 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go @@ -140,7 +140,63 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { tmpl.IssuingCertificateURL = []string{"https://x99.i.lencr.org/"} }, want: lint.Error, - wantSubStr: "authorityInformationAccess caIssuers URI does not use the http scheme", + wantSubStr: "authorityInformationAccess caIssuers URI is not an http URL", + }, + { + name: "aia_unparseable", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"http://x99.i.lencr.org/%zz"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI is not an http URL", + }, + { + name: "aia_no_hostname", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"http:///x99.crt"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix", + }, + { + name: "aia_bad_tld", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.IssuingCertificateURL = []string{"http://x99.i.lencr.invalid/"} + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix", + }, + { + name: "https_crldp", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"https://x99.c.lencr.org/1.crl"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI is not an http URL", + }, + { + name: "crldp_unparseable", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"http://x99.c.lencr.org/%zz"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI is not an http URL", + }, + { + name: "crldp_no_hostname", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"http:///1.crl"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI hostname is not a domain under a public suffix", + }, + { + name: "crldp_bad_tld", + mod: func(t *testing.T, tmpl *x509.Certificate) { + tmpl.CRLDistributionPoints = []string{"http://x99.c.lencr.invalid/1.crl"} + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints URI hostname is not a domain under a public suffix", }, { name: "aia_contains_ocsp", From 34534dc9c99928a1802c6b7cf62be0e9d00b6fc9 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 21 Jul 2026 18:56:41 -0700 Subject: [PATCH 09/18] Restore old lints with added IneffectiveDates --- ...t_validity_period_greater_than_25_years.go | 50 +++++++++++++++++++ ...rt_validity_period_greater_than_8_years.go | 50 +++++++++++++++++++ ...ber_cert_validity_greater_than_100_days.go | 50 +++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go create mode 100644 linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go create mode 100644 linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go diff --git a/linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go b/linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go new file mode 100644 index 00000000000..d827e518cfc --- /dev/null +++ b/linter/lints/cpcps/lint_root_ca_cert_validity_period_greater_than_25_years.go @@ -0,0 +1,50 @@ +package cpcps + +import ( + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type rootCACertValidityTooLong struct{} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_root_ca_cert_validity_period_greater_than_25_years", + Description: "Let's Encrypt Root CA Certificates have Validity Periods of up to 25 years", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + IneffectiveDate: lints.CPSV62Date, + }, + Lint: NewRootCACertValidityTooLong, + }) +} + +func NewRootCACertValidityTooLong() lint.CertificateLintInterface { + return &rootCACertValidityTooLong{} +} + +func (l *rootCACertValidityTooLong) CheckApplies(c *x509.Certificate) bool { + return util.IsRootCA(c) +} + +func (l *rootCACertValidityTooLong) Execute(c *x509.Certificate) *lint.LintResult { + // CPS 7.1: "Root CA Certificate Validity Period: Up to 25 years." + maxValidity := 25 * 365 * lints.BRDay + + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." + certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) + + if certValidity > maxValidity { + return &lint.LintResult{Status: lint.Error} + } + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go b/linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go new file mode 100644 index 00000000000..b053b817537 --- /dev/null +++ b/linter/lints/cpcps/lint_subordinate_ca_cert_validity_period_greater_than_8_years.go @@ -0,0 +1,50 @@ +package cpcps + +import ( + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type subordinateCACertValidityTooLong struct{} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_validity_period_greater_than_8_years", + Description: "Let's Encrypt Intermediate CA Certificates have Validity Periods of up to 8 years", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + IneffectiveDate: lints.CPSV62Date, + }, + Lint: NewSubordinateCACertValidityTooLong, + }) +} + +func NewSubordinateCACertValidityTooLong() lint.CertificateLintInterface { + return &subordinateCACertValidityTooLong{} +} + +func (l *subordinateCACertValidityTooLong) CheckApplies(c *x509.Certificate) bool { + return util.IsSubCA(c) +} + +func (l *subordinateCACertValidityTooLong) Execute(c *x509.Certificate) *lint.LintResult { + // CPS 7.1: "Intermediate CA Certificate Validity Period: Up to 8 years." + maxValidity := 8 * 365 * lints.BRDay + + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." + certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) + + if certValidity > maxValidity { + return &lint.LintResult{Status: lint.Error} + } + + return &lint.LintResult{Status: lint.Pass} +} diff --git a/linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go b/linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go new file mode 100644 index 00000000000..7f0deb2fa51 --- /dev/null +++ b/linter/lints/cpcps/lint_subscriber_cert_validity_greater_than_100_days.go @@ -0,0 +1,50 @@ +package cpcps + +import ( + "time" + + "github.com/zmap/zcrypto/x509" + "github.com/zmap/zlint/v3/lint" + "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/lints" +) + +type subscriberCertValidityTooLong struct{} + +func init() { + lint.RegisterCertificateLint(&lint.CertificateLint{ + LintMetadata: lint.LintMetadata{ + Name: "e_subscriber_cert_validity_period_greater_than_100_days", + Description: "Let's Encrypt Subscriber Certificates have Validity Periods of up to 100 days", + Citation: "CPS: 7.1", + Source: lints.LetsEncryptCPS, + EffectiveDate: lints.CPSV33Date, + IneffectiveDate: lints.CPSV62Date, + }, + Lint: NewSubscriberCertValidityTooLong, + }) +} + +func NewSubscriberCertValidityTooLong() lint.CertificateLintInterface { + return &subscriberCertValidityTooLong{} +} + +func (l *subscriberCertValidityTooLong) CheckApplies(c *x509.Certificate) bool { + return util.IsServerAuthCert(c) && !c.IsCA +} + +func (l *subscriberCertValidityTooLong) Execute(c *x509.Certificate) *lint.LintResult { + // CPS 7.1: "DV SSL End Entity Certificate Validity Period: Up to 100 days." + maxValidity := 100 * lints.BRDay + + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." + certValidity := c.NotAfter.Add(time.Second).Sub(c.NotBefore) + + if certValidity > maxValidity { + return &lint.LintResult{Status: lint.Error} + } + + return &lint.LintResult{Status: lint.Pass} +} From dec0d23606bf8b7bac51b49836ee30b464004bbb Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 21 Jul 2026 19:36:24 -0700 Subject: [PATCH 10/18] Add checks for Section 6.1.6 key quality --- linter/lints/cpcps/helpers.go | 28 +++++++++++++ ...ss_certified_subordinate_ca_certificate.go | 35 +++++++++++++++-- ...rtified_subordinate_ca_certificate_test.go | 33 +++++++++++++++- .../lints/cpcps/lint_root_ca_certificate.go | 35 +++++++++++++++-- .../lint_subscriber_server_certificate.go | 39 +++++++++++++++++-- ...lint_subscriber_server_certificate_test.go | 34 ++++++++++++++-- .../lint_tls_subordinate_ca_certificate.go | 35 +++++++++++++++-- ...int_tls_subordinate_ca_certificate_test.go | 34 ++++++++++++++-- 8 files changed, 249 insertions(+), 24 deletions(-) diff --git a/linter/lints/cpcps/helpers.go b/linter/lints/cpcps/helpers.go index 66da02ec16c..861c3906d37 100644 --- a/linter/lints/cpcps/helpers.go +++ b/linter/lints/cpcps/helpers.go @@ -10,6 +10,7 @@ package cpcps import ( "encoding/pem" "fmt" + "math/big" "github.com/zmap/zcrypto/encoding/asn1" "github.com/zmap/zcrypto/x509" @@ -156,6 +157,33 @@ func getOuterSignatureAlgorithm(der []byte) ([]byte, error) { return signatureAlgorithm, nil } +// smallOddPrimesProduct is the product of all odd primes smaller than 752. +// CP/CPS Section 6.1.6, via NIST SP 800-89 Section 5.3.3, commits us to +// issuing only RSA keys whose moduli are odd and have no factors smaller than +// 752; a single GCD against this product detects any such odd factor +// (evenness is checked separately). To generate the list of primes, run: +// primes 3 752 | tr '\n' , +var smallOddPrimesProduct = func() *big.Int { + product := big.NewInt(1) + for _, prime := range []int64{ + 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, + 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, + 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, + 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229, + 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, + 293, 307, 311, 313, 317, 331, 337, 347, 349, 353, 359, + 367, 373, 379, 383, 389, 397, 401, 409, 419, 421, 431, + 433, 439, 443, 449, 457, 461, 463, 467, 479, 487, 491, + 499, 503, 509, 521, 523, 541, 547, 557, 563, 569, 571, + 577, 587, 593, 599, 601, 607, 613, 617, 619, 631, 641, + 643, 647, 653, 659, 661, 673, 677, 683, 691, 701, 709, + 719, 727, 733, 739, 743, 751, + } { + product.Mul(product, big.NewInt(prime)) + } + return product +}() + // getExtension returns the extension with the given OID, or nil if absent. func getExtension(c *x509.Certificate, oid asn1.ObjectIdentifier) *pkix.Extension { for _, ext := range c.Extensions { diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go index 054ee0547b2..4dc2cc8e533 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -2,9 +2,11 @@ package cpcps import ( "bytes" + "crypto/ecdh" "crypto/elliptic" "encoding/hex" "fmt" + "math/big" "net/url" "time" @@ -143,12 +145,14 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // subjectPublicKeyInfo: the subject of a cross-certificate is an existing // ISRG root, so Section 6.1.5's Root CA requirements apply to its key: // "either RSA keys whose encoded modulus size is 4096 bits, or ECDSA keys - // which are a valid point on the NIST P-384 elliptic curve". Section - // 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte identical - // to a BRs Section 7.1.3.1 encoding. Point-on-curve validation is - // performed by zcrypto at parse time. + // which are a valid point on the NIST P-384 elliptic curve". Section 6.1.6 + // requires the key parameter quality checks performed inline below, and + // Section 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte + // identical to a BRs Section 7.1.3.1 encoding. Point-on-curve validation + // is also performed by zcrypto at parse time. // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1025 // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L852 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L858-L862 // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 spkiAlgID, err := util.GetPublicKeyAidEncoded(c) if err != nil { @@ -162,6 +166,18 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") } + // Section 6.1.6, via NIST SP 800-89 Section 5.3.3, requires that RSA + // keys have "a public exponent of 65537 and an odd modulus which has + // no factors smaller than 752". + if key.E.Cmp(big.NewInt(65537)) != 0 { + return errResult(fmt.Sprintf("RSA public exponent %s is not 65537", key.E)) + } + if key.N.Bit(0) == 0 { + return errResult("RSA modulus is even") + } + if new(big.Int).GCD(nil, nil, key.N, smallOddPrimesProduct).Cmp(big.NewInt(1)) != 0 { + return errResult("RSA modulus has a prime factor smaller than 752") + } case *x509.AugmentedECDSA: if key.Pub.Curve != elliptic.P384() { return errResult(fmt.Sprintf("ECDSA curve %s is not allowed", key.Pub.Curve.Params().Name)) @@ -169,6 +185,17 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 if hex.EncodeToString(spkiAlgID) != spkiAlgorithmP384 { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 encoding for its curve") } + // Section 6.1.6, via NIST SP 800-56A (Revision 2) Section 5.6.2.3.2, + // requires that ECDSA keys comply with the ECC Full Public Key + // Validation Routine. ecdh.Curve.NewPublicKey accepts only a + // well-formed uncompressed point which is on the curve, within the + // underlying field, and not the point at infinity; the routine's + // final step, confirming the point's order, is implied by the others + // for the NIST curves, whose cofactors are 1. + _, err = ecdh.P384().NewPublicKey(key.Raw.Bytes) + if err != nil { + return errResult("ECDSA public key is not a valid uncompressed point on its curve") + } default: return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) } diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go index bed8e92b909..8910d86ecfb 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go @@ -4,8 +4,10 @@ import ( "crypto" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "crypto/x509" "crypto/x509/pkix" + "math/big" "strings" "testing" "time" @@ -56,6 +58,7 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { testCases := []struct { name string + pub crypto.PublicKey mod func(t *testing.T, tmpl *x509.Certificate) want lint.LintStatus wantSubStr string @@ -188,6 +191,27 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "crlDistributionPoints URI hostname is not a domain under a public suffix", }, + { + // 2^4095 + 1 is a 4096-bit odd modulus, but the exponent is wrong. + name: "rsa_exponent_not_65537", + pub: &rsa.PublicKey{N: new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 4095), big.NewInt(1)), E: 3}, + want: lint.Error, + wantSubStr: "RSA public exponent 3 is not 65537", + }, + { + // 2^4095 is a 4096-bit modulus, but it is even. + name: "rsa_modulus_even", + pub: &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 4095), E: 65537}, + want: lint.Error, + wantSubStr: "RSA modulus is even", + }, + { + // 2^4095 + 1 is a 4096-bit odd modulus, but is divisible by 3. + name: "rsa_modulus_small_factor", + pub: &rsa.PublicKey{N: new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 4095), big.NewInt(1)), E: 65537}, + want: lint.Error, + wantSubStr: "RSA modulus has a prime factor smaller than 752", + }, } for _, tc := range testCases { @@ -203,12 +227,17 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { t.Fatalf("creating existing CA certificate: %s", err) } - tmpl := testCrossCertTemplate(t, crossKey.Public()) + pub := crypto.PublicKey(crossKey.Public()) + if tc.pub != nil { + pub = tc.pub + } + + tmpl := testCrossCertTemplate(t, pub) if tc.mod != nil { tc.mod(t, tmpl) } - der, err := x509.CreateCertificate(rand.Reader, tmpl, rootTmpl, crossKey.Public(), rootKey) + der, err := x509.CreateCertificate(rand.Reader, tmpl, rootTmpl, pub, rootKey) if err != nil { t.Fatalf("creating test certificate: %s", err) } diff --git a/linter/lints/cpcps/lint_root_ca_certificate.go b/linter/lints/cpcps/lint_root_ca_certificate.go index d1842449233..84f05dba985 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate.go +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -2,10 +2,12 @@ package cpcps import ( "bytes" + "crypto/ecdh" "crypto/elliptic" stdx509 "crypto/x509" "encoding/hex" "fmt" + "math/big" "time" "github.com/zmap/zcrypto/encoding/asn1" @@ -115,12 +117,14 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. // subjectPublicKeyInfo: Section 6.1.5 says Root CA key pairs are "either // RSA keys whose encoded modulus size is 4096 bits, or ECDSA keys which - // are a valid point on the NIST P-384 elliptic curve", and Section 7.1.3.1 - // requires the AlgorithmIdentifier to be byte-for-byte identical to a BRs - // Section 7.1.3.1 encoding. Point-on-curve validation is performed by - // zcrypto at parse time. + // are a valid point on the NIST P-384 elliptic curve", Section 6.1.6 + // requires the key parameter quality checks performed inline below, and + // Section 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte + // identical to a BRs Section 7.1.3.1 encoding. Point-on-curve validation + // is also performed by zcrypto at parse time. // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1003 // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L852 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L858-L862 // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 spkiAlgID, err := util.GetPublicKeyAidEncoded(c) if err != nil { @@ -134,6 +138,18 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") } + // Section 6.1.6, via NIST SP 800-89 Section 5.3.3, requires that RSA + // keys have "a public exponent of 65537 and an odd modulus which has + // no factors smaller than 752". + if key.E.Cmp(big.NewInt(65537)) != 0 { + return errResult(fmt.Sprintf("RSA public exponent %s is not 65537", key.E)) + } + if key.N.Bit(0) == 0 { + return errResult("RSA modulus is even") + } + if new(big.Int).GCD(nil, nil, key.N, smallOddPrimesProduct).Cmp(big.NewInt(1)) != 0 { + return errResult("RSA modulus has a prime factor smaller than 752") + } case *x509.AugmentedECDSA: if key.Pub.Curve != elliptic.P384() { return errResult(fmt.Sprintf("ECDSA curve %s is not allowed", key.Pub.Curve.Params().Name)) @@ -141,6 +157,17 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. if hex.EncodeToString(spkiAlgID) != spkiAlgorithmP384 { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 encoding for its curve") } + // Section 6.1.6, via NIST SP 800-56A (Revision 2) Section 5.6.2.3.2, + // requires that ECDSA keys comply with the ECC Full Public Key + // Validation Routine. ecdh.Curve.NewPublicKey accepts only a + // well-formed uncompressed point which is on the curve, within the + // underlying field, and not the point at infinity; the routine's + // final step, confirming the point's order, is implied by the others + // for the NIST curves, whose cofactors are 1. + _, err = ecdh.P384().NewPublicKey(key.Raw.Bytes) + if err != nil { + return errResult("ECDSA public key is not a valid uncompressed point on its curve") + } default: return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) } diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate.go b/linter/lints/cpcps/lint_subscriber_server_certificate.go index 05279aec242..c1ccbb51f6c 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -2,10 +2,12 @@ package cpcps import ( "bytes" + "crypto/ecdh" "crypto/elliptic" stdx509 "crypto/x509" "encoding/hex" "fmt" + "math/big" "net" "net/url" "slices" @@ -213,12 +215,14 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // subjectPublicKeyInfo: Section 6.1.5 says public keys in Subscriber // Certificates are "either RSA keys whose encoded modulus size is 2048, // 3072, or 4096 bits; or ECDSA keys which are a valid point on the NIST - // P-256, P-384, or P-521 elliptic curves", and Section 7.1.3.1 requires - // the AlgorithmIdentifier to be byte-for-byte identical to a BRs Section - // 7.1.3.1 encoding. Point-on-curve validation is performed by zcrypto at - // parse time. + // P-256, P-384, or P-521 elliptic curves", Section 6.1.6 requires the key + // parameter quality checks performed inline below, and Section 7.1.3.1 + // requires the AlgorithmIdentifier to be byte-for-byte identical to a BRs + // Section 7.1.3.1 encoding. Point-on-curve validation is also performed + // by zcrypto at parse time. // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1079 // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L856 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L858-L862 // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 spkiAlgID, err := util.GetPublicKeyAidEncoded(c) if err != nil { @@ -232,21 +236,48 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") } + // Section 6.1.6, via NIST SP 800-89 Section 5.3.3, requires that RSA + // keys have "a public exponent of 65537 and an odd modulus which has + // no factors smaller than 752". + if key.E.Cmp(big.NewInt(65537)) != 0 { + return errResult(fmt.Sprintf("RSA public exponent %s is not 65537", key.E)) + } + if key.N.Bit(0) == 0 { + return errResult("RSA modulus is even") + } + if new(big.Int).GCD(nil, nil, key.N, smallOddPrimesProduct).Cmp(big.NewInt(1)) != 0 { + return errResult("RSA modulus has a prime factor smaller than 752") + } case *x509.AugmentedECDSA: var wantAlgID string + var ecdhCurve ecdh.Curve switch key.Pub.Curve { case elliptic.P256(): wantAlgID = spkiAlgorithmP256 + ecdhCurve = ecdh.P256() case elliptic.P384(): wantAlgID = spkiAlgorithmP384 + ecdhCurve = ecdh.P384() case elliptic.P521(): wantAlgID = spkiAlgorithmP521 + ecdhCurve = ecdh.P521() default: return errResult(fmt.Sprintf("ECDSA curve %s is not allowed", key.Pub.Curve.Params().Name)) } if hex.EncodeToString(spkiAlgID) != wantAlgID { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 encoding for its curve") } + // Section 6.1.6, via NIST SP 800-56A (Revision 2) Section 5.6.2.3.2, + // requires that ECDSA keys comply with the ECC Full Public Key + // Validation Routine. ecdh.Curve.NewPublicKey accepts only a + // well-formed uncompressed point which is on the curve, within the + // underlying field, and not the point at infinity; the routine's + // final step, confirming the point's order, is implied by the others + // for the NIST curves, whose cofactors are 1. + _, err = ecdhCurve.NewPublicKey(key.Raw.Bytes) + if err != nil { + return errResult("ECDSA public key is not a valid uncompressed point on its curve") + } default: return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) } diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go index 7cb476e07cb..a9de2390445 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go @@ -1,12 +1,15 @@ package cpcps import ( + "crypto" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" "fmt" + "math/big" "net" "strings" "testing" @@ -27,6 +30,7 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { testCases := []struct { name string + pub crypto.PublicKey mod func(t *testing.T, tmpl *x509.Certificate) want lint.LintStatus wantSubStr string @@ -248,6 +252,27 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "crlDistributionPoints URI hostname is not a domain under a public suffix", }, + { + // 2^2047 + 1 is a 2048-bit odd modulus, but the exponent is wrong. + name: "rsa_exponent_not_65537", + pub: &rsa.PublicKey{N: new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 2047), big.NewInt(1)), E: 3}, + want: lint.Error, + wantSubStr: "RSA public exponent 3 is not 65537", + }, + { + // 2^2047 is a 2048-bit modulus, but it is even. + name: "rsa_modulus_even", + pub: &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 2047), E: 65537}, + want: lint.Error, + wantSubStr: "RSA modulus is even", + }, + { + // 2^2047 + 1 is a 2048-bit odd modulus, but is divisible by 3. + name: "rsa_modulus_small_factor", + pub: &rsa.PublicKey{N: new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 2047), big.NewInt(1)), E: 65537}, + want: lint.Error, + wantSubStr: "RSA modulus has a prime factor smaller than 752", + }, { name: "missing_crldp", mod: func(t *testing.T, tmpl *x509.Certificate) { @@ -308,16 +333,19 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - leafKey := testKey(t, elliptic.P256()) + pub := crypto.PublicKey(testKey(t, elliptic.P256()).Public()) + if tc.pub != nil { + pub = tc.pub + } tmpl := testLeafTemplate(t) if tc.name == "good_with_skid" { - tmpl.SubjectKeyId = testRFC7093SKID(t, leafKey.Public()) + tmpl.SubjectKeyId = testRFC7093SKID(t, pub) } if tc.mod != nil { tc.mod(t, tmpl) } - der, err := x509.CreateCertificate(rand.Reader, tmpl, intTmpl, leafKey.Public(), intKey) + der, err := x509.CreateCertificate(rand.Reader, tmpl, intTmpl, pub, intKey) if err != nil { t.Fatalf("creating test certificate: %s", err) } diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go index 2d48149b3db..e166660ad14 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -2,10 +2,12 @@ package cpcps import ( "bytes" + "crypto/ecdh" "crypto/elliptic" stdx509 "crypto/x509" "encoding/hex" "fmt" + "math/big" "net/url" "time" @@ -146,12 +148,14 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // subjectPublicKeyInfo: Section 6.1.5 says Subordinate CA key pairs are // "either RSA keys whose encoded modulus size is 2048 bits, or ECDSA keys - // which are a valid point on the NIST P-384 elliptic curve", and Section - // 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte identical - // to a BRs Section 7.1.3.1 encoding. Point-on-curve validation is - // performed by zcrypto at parse time. + // which are a valid point on the NIST P-384 elliptic curve", Section 6.1.6 + // requires the key parameter quality checks performed inline below, and + // Section 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte + // identical to a BRs Section 7.1.3.1 encoding. Point-on-curve validation + // is also performed by zcrypto at parse time. // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1052 // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L854 + // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L858-L862 // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 spkiAlgID, err := util.GetPublicKeyAidEncoded(c) if err != nil { @@ -165,6 +169,18 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") } + // Section 6.1.6, via NIST SP 800-89 Section 5.3.3, requires that RSA + // keys have "a public exponent of 65537 and an odd modulus which has + // no factors smaller than 752". + if key.E.Cmp(big.NewInt(65537)) != 0 { + return errResult(fmt.Sprintf("RSA public exponent %s is not 65537", key.E)) + } + if key.N.Bit(0) == 0 { + return errResult("RSA modulus is even") + } + if new(big.Int).GCD(nil, nil, key.N, smallOddPrimesProduct).Cmp(big.NewInt(1)) != 0 { + return errResult("RSA modulus has a prime factor smaller than 752") + } case *x509.AugmentedECDSA: if key.Pub.Curve != elliptic.P384() { return errResult(fmt.Sprintf("ECDSA curve %s is not allowed", key.Pub.Curve.Params().Name)) @@ -172,6 +188,17 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica if hex.EncodeToString(spkiAlgID) != spkiAlgorithmP384 { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 encoding for its curve") } + // Section 6.1.6, via NIST SP 800-56A (Revision 2) Section 5.6.2.3.2, + // requires that ECDSA keys comply with the ECC Full Public Key + // Validation Routine. ecdh.Curve.NewPublicKey accepts only a + // well-formed uncompressed point which is on the curve, within the + // underlying field, and not the point at infinity; the routine's + // final step, confirming the point's order, is implied by the others + // for the NIST curves, whose cofactors are 1. + _, err = ecdh.P384().NewPublicKey(key.Raw.Bytes) + if err != nil { + return errResult("ECDSA public key is not a valid uncompressed point on its curve") + } default: return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) } diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go index d9dfc2ecb6b..0e4316394c4 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go @@ -1,11 +1,14 @@ package cpcps import ( + "crypto" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" + "math/big" "strings" "testing" "time" @@ -26,6 +29,7 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { testCases := []struct { name string curve elliptic.Curve + pub crypto.PublicKey mod func(t *testing.T, tmpl *x509.Certificate) want lint.LintStatus wantSubStr string @@ -198,6 +202,27 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "crlDistributionPoints URI hostname is not a domain under a public suffix", }, + { + // 2^2047 + 1 is a 2048-bit odd modulus, but the exponent is wrong. + name: "rsa_exponent_not_65537", + pub: &rsa.PublicKey{N: new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 2047), big.NewInt(1)), E: 3}, + want: lint.Error, + wantSubStr: "RSA public exponent 3 is not 65537", + }, + { + // 2^2047 is a 2048-bit modulus, but it is even. + name: "rsa_modulus_even", + pub: &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 2047), E: 65537}, + want: lint.Error, + wantSubStr: "RSA modulus is even", + }, + { + // 2^2047 + 1 is a 2048-bit odd modulus, but is divisible by 3. + name: "rsa_modulus_small_factor", + pub: &rsa.PublicKey{N: new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 2047), big.NewInt(1)), E: 65537}, + want: lint.Error, + wantSubStr: "RSA modulus has a prime factor smaller than 752", + }, { name: "aia_contains_ocsp", mod: func(t *testing.T, tmpl *x509.Certificate) { @@ -234,14 +259,17 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { if curve == nil { curve = elliptic.P384() } - intKey := testKey(t, curve) + pub := crypto.PublicKey(testKey(t, curve).Public()) + if tc.pub != nil { + pub = tc.pub + } - tmpl := testIntermediateTemplate(t, intKey.Public()) + tmpl := testIntermediateTemplate(t, pub) if tc.mod != nil { tc.mod(t, tmpl) } - der, err := x509.CreateCertificate(rand.Reader, tmpl, rootTmpl, intKey.Public(), rootKey) + der, err := x509.CreateCertificate(rand.Reader, tmpl, rootTmpl, pub, rootKey) if err != nil { t.Fatalf("creating test certificate: %s", err) } From f33920e5682a1c82da706bc87a96e769b89287ab Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Fri, 24 Jul 2026 11:41:49 -0700 Subject: [PATCH 11/18] Update checks for serial length --- ...ross_certified_subordinate_ca_certificate.go | 11 ++++++----- ...certified_subordinate_ca_certificate_test.go | 17 +++++++++++++++++ linter/lints/cpcps/lint_precertificate_test.go | 14 ++++++++++++-- linter/lints/cpcps/lint_root_ca_certificate.go | 11 ++++++----- .../cpcps/lint_root_ca_certificate_test.go | 13 +++++++++++-- .../cpcps/lint_subscriber_server_certificate.go | 11 ++++++----- .../lint_subscriber_server_certificate_test.go | 13 +++++++++++-- .../lint_tls_subordinate_ca_certificate.go | 11 ++++++----- .../lint_tls_subordinate_ca_certificate_test.go | 15 ++++++++++++--- 9 files changed, 87 insertions(+), 29 deletions(-) diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go index 4dc2cc8e533..0a4159df5e4 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -83,15 +83,16 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("version is not v3") } - // serialNumber is "Approximately 128 bits, including at least 64 bits of - // output from a CSPRNG". We can't test randomness here, but a 128-bit - // serial occupies exactly 16 bytes. + // serialNumber is "More than 100 bits of output from a CSPRNG, optionally + // with additional non-random bits". We can't test randomness here, but a + // serial containing more than 100 bits of CSPRNG output must itself be + // more than 100 bits long. // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1020 if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { return errResult("serialNumber is not a positive integer") } - if (c.SerialNumber.BitLen()+7)/8 != 16 { - return errResult("serialNumber is not approximately 128 bits") + if c.SerialNumber.BitLen() <= 100 { + return errResult("serialNumber is not more than 100 bits long") } // signature is byte-for-byte identical to one of the hexadecimal encodings diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go index 8910d86ecfb..f46853d0af0 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go @@ -67,6 +67,23 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { name: "good", want: lint.Pass, }, + { + name: "good_minimal_serial", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // Exactly 101 bits, the smallest permitted length. + tmpl.SerialNumber = new(big.Int).Lsh(big.NewInt(1), 100) + }, + want: lint.Pass, + }, + { + name: "serial_too_short", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // Exactly 100 bits, one bit short of the required minimum. + tmpl.SerialNumber = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 100), big.NewInt(1)) + }, + want: lint.Error, + wantSubStr: "serialNumber is not more than 100 bits long", + }, { name: "pathlen_mismatch", mod: func(t *testing.T, tmpl *x509.Certificate) { diff --git a/linter/lints/cpcps/lint_precertificate_test.go b/linter/lints/cpcps/lint_precertificate_test.go index c28bf525abb..5711afb327f 100644 --- a/linter/lints/cpcps/lint_precertificate_test.go +++ b/linter/lints/cpcps/lint_precertificate_test.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/x509" "crypto/x509/pkix" + "math/big" "strings" "testing" @@ -71,13 +72,22 @@ func TestPrecertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "validity is more than 100 days", }, + { + name: "good_minimal_serial", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // Exactly 101 bits, the smallest permitted length. + tmpl.SerialNumber = new(big.Int).Lsh(big.NewInt(1), 100) + }, + want: lint.Pass, + }, { name: "serial_too_short", mod: func(t *testing.T, tmpl *x509.Certificate) { - tmpl.SerialNumber = testSerial(t, 16) + // Exactly 100 bits, one bit short of the required minimum. + tmpl.SerialNumber = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 100), big.NewInt(1)) }, want: lint.Error, - wantSubStr: "serialNumber is not approximately 144 bits", + wantSubStr: "serialNumber is not more than 100 bits long", }, } diff --git a/linter/lints/cpcps/lint_root_ca_certificate.go b/linter/lints/cpcps/lint_root_ca_certificate.go index 84f05dba985..80b1e264ffb 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate.go +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -58,15 +58,16 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("version is not v3") } - // serialNumber is "Approximately 128 bits, including at least 64 bits of - // output from a CSPRNG". We can't test randomness here, but a 128-bit - // serial occupies exactly 16 bytes. + // serialNumber is "More than 100 bits of output from a CSPRNG, optionally + // with additional non-random bits". We can't test randomness here, but a + // serial containing more than 100 bits of CSPRNG output must itself be + // more than 100 bits long. // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L998 if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { return errResult("serialNumber is not a positive integer") } - if (c.SerialNumber.BitLen()+7)/8 != 16 { - return errResult("serialNumber is not approximately 128 bits") + if c.SerialNumber.BitLen() <= 100 { + return errResult("serialNumber is not more than 100 bits long") } // signature is byte-for-byte identical to one of the hexadecimal encodings diff --git a/linter/lints/cpcps/lint_root_ca_certificate_test.go b/linter/lints/cpcps/lint_root_ca_certificate_test.go index 902b549bd84..261286b47d9 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_root_ca_certificate_test.go @@ -36,13 +36,22 @@ func TestRootCACertificateMatchesCPSProfile(t *testing.T) { }, want: lint.Pass, }, + { + name: "good_minimal_serial", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // Exactly 101 bits, the smallest permitted length. + tmpl.SerialNumber = new(big.Int).Lsh(big.NewInt(1), 100) + }, + want: lint.Pass, + }, { name: "serial_too_short", mod: func(t *testing.T, tmpl *x509.Certificate) { - tmpl.SerialNumber = big.NewInt(12345) + // Exactly 100 bits, one bit short of the required minimum. + tmpl.SerialNumber = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 100), big.NewInt(1)) }, want: lint.Error, - wantSubStr: "serialNumber is not approximately 128 bits", + wantSubStr: "serialNumber is not more than 100 bits long", }, { name: "validity_too_long", diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate.go b/linter/lints/cpcps/lint_subscriber_server_certificate.go index c1ccbb51f6c..e3aecd41b4e 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -143,15 +143,16 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("version is not v3") } - // serialNumber is "Approximately 144 bits, including at least 64 bits of - // output from a CSPRNG". We can't test randomness here, but a 144-bit - // serial occupies exactly 18 bytes. + // serialNumber is "More than 100 bits of output from a CSPRNG, optionally + // with additional non-random bits". We can't test randomness here, but a + // serial containing more than 100 bits of CSPRNG output must itself be + // more than 100 bits long. // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1074 if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { return errResult("serialNumber is not a positive integer") } - if (c.SerialNumber.BitLen()+7)/8 != 18 { - return errResult("serialNumber is not approximately 144 bits") + if c.SerialNumber.BitLen() <= 100 { + return errResult("serialNumber is not more than 100 bits long") } // signature is byte-for-byte identical to one of the hexadecimal encodings diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go index a9de2390445..d6f3c7e7b8f 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go @@ -79,13 +79,22 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "validity is negative", }, + { + name: "good_minimal_serial", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // Exactly 101 bits, the smallest permitted length. + tmpl.SerialNumber = new(big.Int).Lsh(big.NewInt(1), 100) + }, + want: lint.Pass, + }, { name: "serial_too_short", mod: func(t *testing.T, tmpl *x509.Certificate) { - tmpl.SerialNumber = testSerial(t, 16) + // Exactly 100 bits, one bit short of the required minimum. + tmpl.SerialNumber = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 100), big.NewInt(1)) }, want: lint.Error, - wantSubStr: "serialNumber is not approximately 144 bits", + wantSubStr: "serialNumber is not more than 100 bits long", }, { name: "cn_not_in_sans", diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go index e166660ad14..92244342638 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -88,15 +88,16 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("version is not v3") } - // serialNumber is "Approximately 128 bits, including at least 64 bits of - // output from a CSPRNG". We can't test randomness here, but a 128-bit - // serial occupies exactly 16 bytes. + // serialNumber is "More than 100 bits of output from a CSPRNG, optionally + // with additional non-random bits". We can't test randomness here, but a + // serial containing more than 100 bits of CSPRNG output must itself be + // more than 100 bits long. // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1047 if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { return errResult("serialNumber is not a positive integer") } - if (c.SerialNumber.BitLen()+7)/8 != 16 { - return errResult("serialNumber is not approximately 128 bits") + if c.SerialNumber.BitLen() <= 100 { + return errResult("serialNumber is not more than 100 bits long") } // signature is byte-for-byte identical to one of the hexadecimal encodings diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go index 0e4316394c4..07019831cd7 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go @@ -56,12 +56,21 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { wantSubStr: "validity is negative", }, { - name: "serial_too_long", + name: "good_minimal_serial", mod: func(t *testing.T, tmpl *x509.Certificate) { - tmpl.SerialNumber = testSerial(t, 18) + // Exactly 101 bits, the smallest permitted length. + tmpl.SerialNumber = new(big.Int).Lsh(big.NewInt(1), 100) + }, + want: lint.Pass, + }, + { + name: "serial_too_short", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // Exactly 100 bits, one bit short of the required minimum. + tmpl.SerialNumber = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 100), big.NewInt(1)) }, want: lint.Error, - wantSubStr: "serialNumber is not approximately 128 bits", + wantSubStr: "serialNumber is not more than 100 bits long", }, { name: "wrong_organization", From c29ea743fd93e712d7d9ca9ccb3b32ce7c25b248 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Fri, 24 Jul 2026 12:00:45 -0700 Subject: [PATCH 12/18] Rewrite comments to be mechanically verifiable --- linter/lints/cpcps/helpers.go | 16 +- ...ss_certified_subordinate_ca_certificate.go | 155 ++++++++-------- linter/lints/cpcps/lint_precertificate.go | 32 ++-- .../lints/cpcps/lint_root_ca_certificate.go | 109 ++++++----- .../lint_subscriber_server_certificate.go | 170 +++++++++--------- .../lint_tls_subordinate_ca_certificate.go | 143 +++++++-------- 6 files changed, 297 insertions(+), 328 deletions(-) diff --git a/linter/lints/cpcps/helpers.go b/linter/lints/cpcps/helpers.go index 861c3906d37..df62423d223 100644 --- a/linter/lints/cpcps/helpers.go +++ b/linter/lints/cpcps/helpers.go @@ -21,10 +21,10 @@ import ( ) var ( - // The AlgorithmIdentifier encodings specified by Section 7.1.3.2 of the - // Baseline Requirements, which Section 7.1.3.2 of our CP/CPS incorporates - // by reference. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1117 + // When used in the context of a signature, fields of type `AlgorithmIdentifier` of all objects signed by ISRG CAs are byte-for-byte identical with one of the hexadecimal encodings specified by Section 7.1.3.2 of the Baseline Requirements. + // These are the AlgorithmIdentifier encodings specified by Section + // 7.1.3.2 of the Baseline Requirements. brSignatureAlgorithmIdentifiers = map[string]bool{ // sha256WithRSAEncryption "300d06092a864886f70d01010b0500": true, @@ -40,10 +40,10 @@ var ( "300a06082a8648ce3d040304": true, } - // The SubjectPublicKeyInfo AlgorithmIdentifier encodings specified by - // Section 7.1.3.1 of the Baseline Requirements, which Section 7.1.3.1 of - // our CP/CPS incorporates by reference. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1113 + // The `AlgorithmIdentifier` field of the `SubjectPublicKeyInfo` field of ISRG Certificates is byte-for-byte identical with one of the hexadecimal encodings specified by Section 7.1.3.1 of the Baseline Requirements. + // These are the SubjectPublicKeyInfo AlgorithmIdentifier encodings + // specified by Section 7.1.3.1 of the Baseline Requirements. spkiAlgorithmRSA = "300d06092a864886f70d0101010500" spkiAlgorithmP256 = "301306072a8648ce3d020106082a8648ce3d030107" spkiAlgorithmP384 = "301006072a8648ce3d020106052b81040022" diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go index 0a4159df5e4..78939dcbd0e 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -54,7 +54,8 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) CheckApplies(c // Execute checks the given certificate against the Cross-Certified Subordinate // CA Certificate Profile, row by row. -// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1014-L1039 +// https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1014 +// ### Cross-Certified Subordinate CA Certificate Profile func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { // Several rows of the profile require byte-for-byte correspondence with // the Issuing CA's certificate or with the existing CA Certificate being @@ -74,20 +75,19 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return fatalResult("lint has not been configured with the existing CA Certificate being cross-signed (existing_certificate)") } - // version is "X.509 version 3" (Section 7.1.1). Note that unlike - // crypto/x509, zcrypto's Version field is one-indexed: it holds 3 (not the - // raw encoded value 2) for a v3 certificate. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1019 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1101-L1103 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1019 + // |     `version` | See [Section 7.1.1](#711-version-numbers) | + // Section 7.1.1 says "All certificates use X.509 version 3". Note that + // unlike crypto/x509, zcrypto's Version field is one-indexed: it holds 3 + // (not the raw encoded value 2) for a v3 certificate. if c.Version != 3 { return errResult("version is not v3") } - // serialNumber is "More than 100 bits of output from a CSPRNG, optionally - // with additional non-random bits". We can't test randomness here, but a - // serial containing more than 100 bits of CSPRNG output must itself be - // more than 100 bits long. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1020 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1020 + // |     `serialNumber` | More than 100 bits of output from a CSPRNG, optionally with additional non-random bits | + // We can't test randomness here, but a serial containing more than 100 + // bits of CSPRNG output must itself be more than 100 bits long. if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { return errResult("serialNumber is not a positive integer") } @@ -95,11 +95,11 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("serialNumber is not more than 100 bits long") } - // signature is byte-for-byte identical to one of the hexadecimal encodings - // specified by Section 7.1.3.2 of the Baseline Requirements (Section - // 7.1.3.2). - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1021 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1021 + // |     `signature` | See [Section 7.1.3.2](#7132-signature-algorithmidentifier) | + // Section 7.1.3.2 requires signature AlgorithmIdentifiers to be + // byte-for-byte identical with one of the hexadecimal encodings specified + // by Section 7.1.3.2 of the Baseline Requirements. tbsSignature, err := util.GetSignatureAlgorithmInTBSEncoded(c) if err != nil { return errResult("failed to parse tbsCertificate.signature") @@ -108,17 +108,16 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") } - // issuer is "Byte-for-byte identical to the subject field of the Issuing - // CA". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1022 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1022 + // |     `issuer` | Byte-for-byte identical to the `subject` field of the Issuing CA | if !bytes.Equal(c.RawIssuer, issuer.RawSubject) { return errResult("issuer is not byte-for-byte identical to the subject of the configured Issuing CA") } - // validity is "At most 1098 days (approx. 3 years)". RFC 5280 4.1.2.5: - // "The validity period for a certificate is the period of time from - // notBefore through notAfter, inclusive." - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1023 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1023 + // |     `validity` | At most 1098 days (approx. 3 years) | + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." if c.NotAfter.Before(c.NotBefore) { return errResult("validity is negative: notAfter is before notBefore") } @@ -126,10 +125,10 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("validity is more than 1098 days") } - // subject is "Byte-for-byte identical to the subject field of the existing - // CA Certificate". The shape checks below are implied by the byte-for-byte - // comparison, but produce more useful error messages. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1024 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1024 + // |     `subject` | Byte-for-byte identical to the `subject` field of the existing CA Certificate | + // The shape checks below are implied by the byte-for-byte comparison, but + // produce more useful error messages. if !bytes.Equal(c.RawSubject, existing.RawSubject) { return errResult("subject is not byte-for-byte identical to the subject of the configured existing CA Certificate") } @@ -143,18 +142,16 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("subject commonName is empty") } - // subjectPublicKeyInfo: the subject of a cross-certificate is an existing - // ISRG root, so Section 6.1.5's Root CA requirements apply to its key: - // "either RSA keys whose encoded modulus size is 4096 bits, or ECDSA keys - // which are a valid point on the NIST P-384 elliptic curve". Section 6.1.6 - // requires the key parameter quality checks performed inline below, and - // Section 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte - // identical to a BRs Section 7.1.3.1 encoding. Point-on-curve validation - // is also performed by zcrypto at parse time. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1025 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L852 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L858-L862 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1025 + // |     `subjectPublicKeyInfo` | See Sections [6.1.5](#615-key-sizes), [6.1.6](#616-public-key-parameters-generation-and-quality-checking), and [7.1.3.1](#7131-subjectpublickeyinfo) | + // The subject of a cross-certificate is an existing ISRG root, so Section + // 6.1.5's Root CA requirements apply to its key: "either RSA keys whose + // encoded modulus size is 4096 bits, or ECDSA keys which are a valid + // point on the NIST P-384 elliptic curve". Section 6.1.6 requires the key + // parameter quality checks performed inline below. Section 7.1.3.1 + // requires the AlgorithmIdentifier to be byte-for-byte identical to a BRs + // Section 7.1.3.1 encoding. Point-on-curve validation is also performed + // by zcrypto at parse time. spkiAlgID, err := util.GetPublicKeyAidEncoded(c) if err != nil { return errResult("failed to parse subjectPublicKeyInfo algorithm") @@ -201,24 +198,24 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) } - // issuerUniqueID is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1026 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1026 + // |     `issuerUniqueID` | Not present | if c.IssuerUniqueId.Bytes != nil { return errResult("issuerUniqueID is present") } - // subjectUniqueID is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1027 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1027 + // |     `subjectUniqueID` | Not present | if c.SubjectUniqueId.Bytes != nil { return errResult("subjectUniqueID is present") } - // authorityInformationAccess "Contains the HTTP URI of the Issuing CA's - // Certificate". Whether the URI actually serves the Issuing CA's - // certificate is not observable here, but the extension must contain - // exactly one caIssuers entry with an http URI and nothing else (in - // particular, no OCSP entries). - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1029 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1029 + // |         `authorityInformationAccess` | Contains the HTTP URI of the Issuing CA's Certificate | + // Whether the URI actually serves the Issuing CA's certificate is not + // observable here, but the extension must contain exactly one caIssuers + // entry with an http URI and nothing else (in particular, no OCSP + // entries). if !util.IsExtInCert(c, authorityInformationAccessOID) { return errResult("authorityInformationAccess extension is not present") } @@ -237,9 +234,8 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix") } - // authorityKeyIdentifier "Contains a keyIdentifier byte-for-byte identical - // to the subjectKeyIdentifier of the Issuing CA". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1030 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1030 + // |         `authorityKeyIdentifier` | Contains a `keyIdentifier` byte-for-byte identical to the `subjectKeyIdentifier` of the Issuing CA | akidExt := getExtension(c, authorityKeyIdentifierOID) if akidExt == nil { return errResult("authorityKeyIdentifier extension is not present") @@ -254,9 +250,8 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("authorityKeyIdentifier keyIdentifier is not byte-for-byte identical to the subjectKeyIdentifier of the configured Issuing CA") } - // basicConstraints is "Critical, with cA set to true and pathLenConstraint - // identical to the existing CA Certificate". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1031 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1031 + // |         `basicConstraints` | Critical, with `cA` set to true and `pathLenConstraint` identical to the existing CA Certificate | bcExt := getExtension(c, basicConstraintsOID) if bcExt == nil { return errResult("basicConstraints extension is not present") @@ -271,9 +266,8 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("basicConstraints pathLenConstraint is not identical to that of the configured existing CA Certificate") } - // certificatePolicies "Contains only the Baseline Requirements Domain - // Validated Reserved Policy Identifier (OID 2.23.140.1.2.1)". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1032 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1032 + // |         `certificatePolicies` | Contains only the Baseline Requirements Domain Validated Reserved Policy Identifier (OID 2.23.140.1.2.1) | if !util.IsExtInCert(c, certificatePoliciesOID) { return errResult("certificatePolicies extension is not present") } @@ -281,10 +275,10 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier") } - // crlDistributionPoints "Contains the HTTP URI of a CRL issued by the - // Issuing CA whose scope includes this certificate". Whether the CRL's - // scope actually includes this certificate is not observable here. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1033 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1033 + // |         `crlDistributionPoints` | Contains the HTTP URI of a CRL issued by the Issuing CA whose scope includes this certificate | + // Whether the CRL's scope actually includes this certificate is not + // observable here. if !util.IsExtInCert(c, crlDistributionPointsOID) { return errResult("crlDistributionPoints extension is not present") } @@ -300,8 +294,8 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("crlDistributionPoints URI hostname is not a domain under a public suffix") } - // extKeyUsage "Contains only id-kp-serverAuth (OID 1.3.6.1.5.5.7.3.1)". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1034 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1034 + // |         `extKeyUsage` | Contains only `id-kp-serverAuth` (OID 1.3.6.1.5.5.7.3.1) | if !util.IsExtInCert(c, extKeyUsageOID) { return errResult("extKeyUsage extension is not present") } @@ -309,9 +303,8 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("extKeyUsage does not contain exactly id-kp-serverAuth") } - // keyUsage is "Critical, with only the keyCertSign (5) and cRLSign (6) - // bits set". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1035 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1035 + // |         `keyUsage` | Critical, with only the `keyCertSign` (5) and `cRLSign` (6) bits set | kuExt := getExtension(c, keyUsageOID) if kuExt == nil { return errResult("keyUsage extension is not present") @@ -323,12 +316,11 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("keyUsage does not assert exactly the bits required by the profile") } - // subjectKeyIdentifier is "Byte-for-byte identical to the - // subjectKeyIdentifier of the existing CA Certificate". Unlike the other - // profiles we cannot assume the RFC 7093 construction here, because the - // existing CA Certificate's subjectKeyIdentifier may have been computed by - // any method. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1036 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1036 + // |         `subjectKeyIdentifier` | Byte-for-byte identical to the `subjectKeyIdentifier` of the existing CA Certificate | + // Unlike the other profiles we cannot assume the RFC 7093 construction + // here, because the existing CA Certificate's subjectKeyIdentifier may + // have been computed by any method. skidExt := getExtension(c, subjectKeyIdentifierOID) if skidExt == nil { return errResult("subjectKeyIdentifier extension is not present") @@ -343,8 +335,8 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("subjectKeyIdentifier is not byte-for-byte identical to that of the configured existing CA Certificate") } - // Any other extension is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1037 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1037 + // |         Any other extension | Not present | allowedExtensions := []asn1.ObjectIdentifier{ authorityInformationAccessOID, authorityKeyIdentifierOID, @@ -367,9 +359,8 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 } } - // signatureAlgorithm is "Byte-for-byte identical to the - // tbsCertificate.signature". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1038 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1038 + // | `signatureAlgorithm` | Byte-for-byte identical to the `tbsCertificate.signature` | signatureAlgorithm, err := getOuterSignatureAlgorithm(c.Raw) if err != nil { return errResult(err.Error()) @@ -378,10 +369,10 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 return errResult("signatureAlgorithm is not byte-for-byte identical to the tbsCertificate.signature") } - // signatureValue is "A signature appropriate to the signatureAlgorithm - // field". We can't verify the signature here: pre-issuance linting - // operates on a certificate signed by a throwaway key. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1039 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1039 + // | `signatureValue` | A signature appropriate to the `signatureAlgorithm` field | + // We can't verify the signature here: pre-issuance linting operates on a + // certificate signed by a throwaway key. return &lint.LintResult{Status: lint.Pass} } diff --git a/linter/lints/cpcps/lint_precertificate.go b/linter/lints/cpcps/lint_precertificate.go index 5df100f1f38..acf8b9af7e6 100644 --- a/linter/lints/cpcps/lint_precertificate.go +++ b/linter/lints/cpcps/lint_precertificate.go @@ -43,23 +43,22 @@ func (l *precertificateMatchesCPSProfile) CheckApplies(c *x509.Certificate) bool return util.IsSubscriberCert(c) && util.IsServerAuthCert(c) && util.IsExtInCert(c, util.CtPoisonOID) } -// Execute checks the given precertificate against the Precertificate Profile, -// which is "Identical to the Subscriber (Server) Certificate Profile, except -// that the SignedCertificateTimestampList extension is omitted, and a critical -// 'CT poison' extension (OID 1.3.6.1.4.1.11129.2.4.3) is included": first the -// rows shared with the Subscriber (Server) Certificate Profile (whose -// implementation lives in lint_subscriber_server_certificate.go), then the -// rows specific to precertificates. -// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1097-L1099 +// Execute checks the given precertificate against the Precertificate Profile: +// first the rows shared with the Subscriber (Server) Certificate Profile +// (whose implementation lives in lint_subscriber_server_certificate.go), then +// the requirements specific to precertificates. +// https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1097 +// ### Precertificate Profile func (l *precertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { res := checkSubscriberProfile(c, l.Config.issuerPEM()) if res != nil { return res } - // In place of the SignedCertificateTimestampList extension, a critical - // "CT poison" extension (OID 1.3.6.1.4.1.11129.2.4.3) is included. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1099 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1099 + // Identical to the Subscriber (Server) Certificate Profile, except that the `SignedCertificateTimestampList` extension is omitted, and a critical "CT poison" extension (OID 1.3.6.1.4.1.11129.2.4.3) is included. ISRG Precertificates are issued directly by the Issuing CA, not by a delegated Precertificate Signing CA. + // This check enforces the presence and criticality of the CT poison + // extension. poisonExt := getExtension(c, util.CtPoisonOID) if poisonExt == nil { return errResult("CT poison extension is not present") @@ -68,11 +67,12 @@ func (l *precertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.Lin return errResult("CT poison extension is not critical") } - // Any other extension is "Not present". In particular, the - // SignedCertificateTimestampList extension is omitted from - // precertificates, so it is not in the allowed set. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1093 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1099 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1093 + // |         Any other extension | Not present | + // The SignedCertificateTimestampList extension is omitted from + // precertificates and replaced by the CT poison extension, so the allowed + // set here differs from the Subscriber (Server) Certificate Profile's by + // exactly that substitution. allowedExtensions := []asn1.ObjectIdentifier{ authorityInformationAccessOID, authorityKeyIdentifierOID, diff --git a/linter/lints/cpcps/lint_root_ca_certificate.go b/linter/lints/cpcps/lint_root_ca_certificate.go index 80b1e264ffb..1fdd3424834 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate.go +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -47,22 +47,22 @@ func (l *rootCACertificateMatchesCPSProfile) CheckApplies(c *x509.Certificate) b // Profile, row by row. Root CA Certificates are self-signed, so unlike the // other profiles there is no Issuing CA to be configured with: the issuer // correspondence is checked against the certificate's own subject. -// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L992-L1012 +// https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L992 +// ### Root CA Certificate Profile func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { - // version is "X.509 version 3" (Section 7.1.1). Note that unlike - // crypto/x509, zcrypto's Version field is one-indexed: it holds 3 (not the - // raw encoded value 2) for a v3 certificate. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L997 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1101-L1103 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L997 + // |     `version` | See [Section 7.1.1](#711-version-numbers) | + // Section 7.1.1 says "All certificates use X.509 version 3". Note that + // unlike crypto/x509, zcrypto's Version field is one-indexed: it holds 3 + // (not the raw encoded value 2) for a v3 certificate. if c.Version != 3 { return errResult("version is not v3") } - // serialNumber is "More than 100 bits of output from a CSPRNG, optionally - // with additional non-random bits". We can't test randomness here, but a - // serial containing more than 100 bits of CSPRNG output must itself be - // more than 100 bits long. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L998 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L998 + // |     `serialNumber` | More than 100 bits of output from a CSPRNG, optionally with additional non-random bits | + // We can't test randomness here, but a serial containing more than 100 + // bits of CSPRNG output must itself be more than 100 bits long. if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { return errResult("serialNumber is not a positive integer") } @@ -70,11 +70,11 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("serialNumber is not more than 100 bits long") } - // signature is byte-for-byte identical to one of the hexadecimal encodings - // specified by Section 7.1.3.2 of the Baseline Requirements (Section - // 7.1.3.2). - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L999 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L999 + // |     `signature` | See [Section 7.1.3.2](#7132-signature-algorithmidentifier) | + // Section 7.1.3.2 requires signature AlgorithmIdentifiers to be + // byte-for-byte identical with one of the hexadecimal encodings specified + // by Section 7.1.3.2 of the Baseline Requirements. tbsSignature, err := util.GetSignatureAlgorithmInTBSEncoded(c) if err != nil { return errResult("failed to parse tbsCertificate.signature") @@ -83,16 +83,16 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") } - // issuer is "Byte-for-byte identical to the subject field". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1000 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1000 + // |     `issuer` | Byte-for-byte identical to the `subject` field | if !bytes.Equal(c.RawIssuer, c.RawSubject) { return errResult("issuer is not byte-for-byte identical to the subject") } - // validity is "At most 3660 days (approx. 10 years)". RFC 5280 4.1.2.5: - // "The validity period for a certificate is the period of time from - // notBefore through notAfter, inclusive." - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1001 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1001 + // |     `validity` | At most 3660 days (approx. 10 years) | + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." if c.NotAfter.Before(c.NotBefore) { return errResult("validity is negative: notAfter is before notBefore") } @@ -100,9 +100,9 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("validity is more than 3660 days") } - // subject is "C=US, O=ISRG, and a unique CN". + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1002 + // |     `subject` | C=US, O=ISRG, and a unique CN | // We can't test for CN uniqueness here, but the rest we can check. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1002 if len(c.Subject.Names) != 3 { return errResult("subject does not contain exactly C, O, and CN attributes") } @@ -116,17 +116,15 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("subject commonName is empty") } - // subjectPublicKeyInfo: Section 6.1.5 says Root CA key pairs are "either - // RSA keys whose encoded modulus size is 4096 bits, or ECDSA keys which - // are a valid point on the NIST P-384 elliptic curve", Section 6.1.6 - // requires the key parameter quality checks performed inline below, and - // Section 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte - // identical to a BRs Section 7.1.3.1 encoding. Point-on-curve validation - // is also performed by zcrypto at parse time. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1003 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L852 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L858-L862 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1003 + // |     `subjectPublicKeyInfo` | See Sections [6.1.5](#615-key-sizes), [6.1.6](#616-public-key-parameters-generation-and-quality-checking), and [7.1.3.1](#7131-subjectpublickeyinfo) | + // Section 6.1.5 says Root CA key pairs are "either RSA keys whose encoded + // modulus size is 4096 bits, or ECDSA keys which are a valid point on the + // NIST P-384 elliptic curve". Section 6.1.6 requires the key parameter + // quality checks performed inline below. Section 7.1.3.1 requires the + // AlgorithmIdentifier to be byte-for-byte identical to a BRs Section + // 7.1.3.1 encoding. Point-on-curve validation is also performed by + // zcrypto at parse time. spkiAlgID, err := util.GetPublicKeyAidEncoded(c) if err != nil { return errResult("failed to parse subjectPublicKeyInfo algorithm") @@ -173,20 +171,20 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) } - // issuerUniqueID is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1004 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1004 + // |     `issuerUniqueID` | Not present | if c.IssuerUniqueId.Bytes != nil { return errResult("issuerUniqueID is present") } - // subjectUniqueID is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1005 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1005 + // |     `subjectUniqueID` | Not present | if c.SubjectUniqueId.Bytes != nil { return errResult("subjectUniqueID is present") } - // basicConstraints is "Critical, with cA set to true". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1007 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1007 + // |         `basicConstraints` | Critical, with `cA` set to true | bcExt := getExtension(c, basicConstraintsOID) if bcExt == nil { return errResult("basicConstraints extension is not present") @@ -198,9 +196,8 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("basicConstraints cA is not true") } - // keyUsage is "Critical, with only the keyCertSign (5) and cRLSign (6) - // bits set". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1008 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1008 + // |         `keyUsage` | Critical, with only the `keyCertSign` (5) and `cRLSign` (6) bits set | kuExt := getExtension(c, keyUsageOID) if kuExt == nil { return errResult("keyUsage extension is not present") @@ -212,9 +209,8 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("keyUsage does not assert exactly the bits required by the profile") } - // subjectKeyIdentifier "Contains a truncated hash of the subjectPublicKey, - // per Section 2(1) of RFC 7093". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1009 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1009 + // |         `subjectKeyIdentifier` | Contains a truncated hash of the `subjectPublicKey`, per Section 2(1) of RFC 7093 | skidExt := getExtension(c, subjectKeyIdentifierOID) if skidExt == nil { return errResult("subjectKeyIdentifier extension is not present") @@ -235,8 +231,8 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("subjectKeyIdentifier is not the RFC 7093 Section 2(1) truncated hash of the subjectPublicKey") } - // Any other extension is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1010 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1010 + // |         Any other extension | Not present | allowedExtensions := []asn1.ObjectIdentifier{ basicConstraintsOID, keyUsageOID, @@ -254,9 +250,8 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. } } - // signatureAlgorithm is "Byte-for-byte identical to the - // tbsCertificate.signature". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1011 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1011 + // | `signatureAlgorithm` | Byte-for-byte identical to the `tbsCertificate.signature` | signatureAlgorithm, err := getOuterSignatureAlgorithm(c.Raw) if err != nil { return errResult(err.Error()) @@ -265,11 +260,11 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. return errResult("signatureAlgorithm is not byte-for-byte identical to the tbsCertificate.signature") } - // signatureValue is "A signature appropriate to the signatureAlgorithm - // field". We can't verify the signature under pre-issuance linting (the - // certificate is signed by a throwaway key), but CheckApplies only selects - // certificates whose self-signature zcrypto has verified. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1012 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1012 + // | `signatureValue` | A signature appropriate to the `signatureAlgorithm` field | + // We can't verify the signature under pre-issuance linting (the + // certificate is signed by a throwaway key), but CheckApplies only + // selects certificates whose self-signature zcrypto has verified. return &lint.LintResult{Status: lint.Pass} } diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate.go b/linter/lints/cpcps/lint_subscriber_server_certificate.go index e3aecd41b4e..395e9c36dbe 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -61,18 +61,19 @@ func (l *subscriberServerCertificateMatchesCPSProfile) CheckApplies(c *x509.Cert // Execute checks the given certificate against the Subscriber (Server) // Certificate Profile: first the rows shared with the Precertificate Profile, // then the rows specific to final certificates. -// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1068-L1095 +// https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1068 +// ### Subscriber (Server) Certificate Profile func (l *subscriberServerCertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { res := checkSubscriberProfile(c, l.Config.issuerPEM()) if res != nil { return res } - // SignedCertificateTimestampList "Contains at least two SCTs from logs - // run by different operators". We can't map log IDs to operators here, - // but SCTs from different operators necessarily come from different - // logs, so we can check that at least two distinct logs are present. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1090 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1090 + // |         `SignedCertificateTimestampList` | Contains at least two SCTs from logs run by different operators | + // We can't map log IDs to operators here, but SCTs from different + // operators necessarily come from different logs, so we can check that at + // least two distinct logs are present. if !util.IsExtInCert(c, sctListOID) { return errResult("signedCertificateTimestampList extension is not present") } @@ -84,8 +85,8 @@ func (l *subscriberServerCertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("signedCertificateTimestampList does not contain SCTs from at least two distinct logs") } - // Any other extension is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1093 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1093 + // |         Any other extension | Not present | allowedExtensions := []asn1.ObjectIdentifier{ authorityInformationAccessOID, authorityKeyIdentifierOID, @@ -122,7 +123,8 @@ func (l *subscriberServerCertificateMatchesCPSProfile) Execute(c *x509.Certifica // differ between the two profiles (SignedCertificateTimestampList or CT // poison, and the set of permitted extensions) are checked by each lint's // Execute method. -// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1068-L1099 +// https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1068 +// ### Subscriber (Server) Certificate Profile func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintResult { // Several rows of the profile require byte-for-byte correspondence with // the Issuing CA's certificate, so this lint must be configured with it. @@ -134,20 +136,19 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return fatalResult("lint has not been configured with the Issuing CA's certificate (issuer_certificate)") } - // version is "X.509 version 3" (Section 7.1.1). Note that unlike - // crypto/x509, zcrypto's Version field is one-indexed: it holds 3 (not the - // raw encoded value 2) for a v3 certificate. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1073 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1101-L1103 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1073 + // |     `version` | See [Section 7.1.1](#711-version-numbers) | + // Section 7.1.1 says "All certificates use X.509 version 3". Note that + // unlike crypto/x509, zcrypto's Version field is one-indexed: it holds 3 + // (not the raw encoded value 2) for a v3 certificate. if c.Version != 3 { return errResult("version is not v3") } - // serialNumber is "More than 100 bits of output from a CSPRNG, optionally - // with additional non-random bits". We can't test randomness here, but a - // serial containing more than 100 bits of CSPRNG output must itself be - // more than 100 bits long. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1074 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1074 + // |     `serialNumber` | More than 100 bits of output from a CSPRNG, optionally with additional non-random bits | + // We can't test randomness here, but a serial containing more than 100 + // bits of CSPRNG output must itself be more than 100 bits long. if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { return errResult("serialNumber is not a positive integer") } @@ -155,11 +156,11 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("serialNumber is not more than 100 bits long") } - // signature is byte-for-byte identical to one of the hexadecimal encodings - // specified by Section 7.1.3.2 of the Baseline Requirements (Section - // 7.1.3.2). - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1075 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1075 + // |     `signature` | See [Section 7.1.3.2](#7132-signature-algorithmidentifier) | + // Section 7.1.3.2 requires signature AlgorithmIdentifiers to be + // byte-for-byte identical with one of the hexadecimal encodings specified + // by Section 7.1.3.2 of the Baseline Requirements. tbsSignature, err := util.GetSignatureAlgorithmInTBSEncoded(c) if err != nil { return errResult("failed to parse tbsCertificate.signature") @@ -168,17 +169,16 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") } - // issuer is "Byte-for-byte identical to the subject field of the Issuing - // CA". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1076 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1076 + // |     `issuer` | Byte-for-byte identical to the `subject` field of the Issuing CA | if !bytes.Equal(c.RawIssuer, issuer.RawSubject) { return errResult("issuer is not byte-for-byte identical to the subject of the configured Issuing CA") } - // validity is "At most 100 days". RFC 5280 4.1.2.5: "The validity period - // for a certificate is the period of time from notBefore through notAfter, - // inclusive." - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1077 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1077 + // |     `validity` | At most 100 days | + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." if c.NotAfter.Before(c.NotBefore) { return errResult("validity is negative: notAfter is before notBefore") } @@ -186,11 +186,10 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("validity is more than 100 days") } - // subject is "CN omitted, or optionally contains one of the values from - // the Subject Alternative Name extension". Per Section 7.1.4, no other - // subject attributes are included in Subscriber Certificates. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1078 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1119-L1121 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1078 + // |     `subject` | CN omitted, or optionally contains one of the values from the Subject Alternative Name extension | + // Per Section 7.1.4, no other subject attributes are included in + // Subscriber Certificates. for _, atv := range c.Subject.Names { if !atv.Type.Equal(commonNameOID) { return errResult(fmt.Sprintf("subject contains an attribute other than commonName: %s", atv.Type.String())) @@ -213,18 +212,16 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes } } - // subjectPublicKeyInfo: Section 6.1.5 says public keys in Subscriber - // Certificates are "either RSA keys whose encoded modulus size is 2048, - // 3072, or 4096 bits; or ECDSA keys which are a valid point on the NIST - // P-256, P-384, or P-521 elliptic curves", Section 6.1.6 requires the key - // parameter quality checks performed inline below, and Section 7.1.3.1 - // requires the AlgorithmIdentifier to be byte-for-byte identical to a BRs - // Section 7.1.3.1 encoding. Point-on-curve validation is also performed - // by zcrypto at parse time. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1079 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L856 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L858-L862 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1079 + // |     `subjectPublicKeyInfo` | See Sections [6.1.5](#615-key-sizes), [6.1.6](#616-public-key-parameters-generation-and-quality-checking), and [7.1.3.1](#7131-subjectpublickeyinfo) | + // Section 6.1.5 says public keys in Subscriber Certificates are "either + // RSA keys whose encoded modulus size is 2048, 3072, or 4096 bits; or + // ECDSA keys which are a valid point on the NIST P-256, P-384, or P-521 + // elliptic curves". Section 6.1.6 requires the key parameter quality + // checks performed inline below. Section 7.1.3.1 requires the + // AlgorithmIdentifier to be byte-for-byte identical to a BRs Section + // 7.1.3.1 encoding. Point-on-curve validation is also performed by + // zcrypto at parse time. spkiAlgID, err := util.GetPublicKeyAidEncoded(c) if err != nil { return errResult("failed to parse subjectPublicKeyInfo algorithm") @@ -283,24 +280,24 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) } - // issuerUniqueID is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1080 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1080 + // |     `issuerUniqueID` | Not present | if c.IssuerUniqueId.Bytes != nil { return errResult("issuerUniqueID is present") } - // subjectUniqueID is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1081 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1081 + // |     `subjectUniqueID` | Not present | if c.SubjectUniqueId.Bytes != nil { return errResult("subjectUniqueID is present") } - // authorityInformationAccess "Contains the HTTP URI of the Issuing CA's - // Certificate". Whether the URI actually serves the Issuing CA's - // certificate is not observable here, but the extension must contain - // exactly one caIssuers entry with an http URI and nothing else (in - // particular, no OCSP entries). - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1083 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1083 + // |         `authorityInformationAccess` | Contains the HTTP URI of the Issuing CA's Certificate | + // Whether the URI actually serves the Issuing CA's certificate is not + // observable here, but the extension must contain exactly one caIssuers + // entry with an http URI and nothing else (in particular, no OCSP + // entries). if !util.IsExtInCert(c, authorityInformationAccessOID) { return errResult("authorityInformationAccess extension is not present") } @@ -319,9 +316,8 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix") } - // authorityKeyIdentifier "Contains a keyIdentifier byte-for-byte identical - // to the subjectKeyIdentifier of the Issuing CA". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1084 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1084 + // |         `authorityKeyIdentifier` | Contains a `keyIdentifier` byte-for-byte identical to the `subjectKeyIdentifier` of the Issuing CA | akidExt := getExtension(c, authorityKeyIdentifierOID) if akidExt == nil { return errResult("authorityKeyIdentifier extension is not present") @@ -336,8 +332,8 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("authorityKeyIdentifier keyIdentifier is not byte-for-byte identical to the subjectKeyIdentifier of the configured Issuing CA") } - // basicConstraints is "Critical, with cA set to false". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1085 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1085 + // |         `basicConstraints` | Critical, with `cA` set to false | bcExt := getExtension(c, basicConstraintsOID) if bcExt == nil { return errResult("basicConstraints extension is not present") @@ -349,9 +345,8 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("basicConstraints cA is not false") } - // certificatePolicies "Contains only the Baseline Requirements Domain - // Validated Reserved Policy Identifier (OID 2.23.140.1.2.1)". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1086 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1086 + // |         `certificatePolicies` | Contains only the Baseline Requirements Domain Validated Reserved Policy Identifier (OID 2.23.140.1.2.1) | if !util.IsExtInCert(c, certificatePoliciesOID) { return errResult("certificatePolicies extension is not present") } @@ -359,10 +354,10 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier") } - // crlDistributionPoints "Contains the HTTP URI of a CRL issued by the - // Issuing CA whose scope includes this certificate". Whether the CRL's - // scope actually includes this certificate is not observable here. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1087 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1087 + // |         `crlDistributionPoints` | Contains the HTTP URI of a CRL issued by the Issuing CA whose scope includes this certificate | + // Whether the CRL's scope actually includes this certificate is not + // observable here. if !util.IsExtInCert(c, crlDistributionPointsOID) { return errResult("crlDistributionPoints extension is not present") } @@ -378,8 +373,8 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("crlDistributionPoints URI hostname is not a domain under a public suffix") } - // extKeyUsage "Contains only id-kp-serverAuth (OID 1.3.6.1.5.5.7.3.1)". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1088 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1088 + // |         `extKeyUsage` | Contains only `id-kp-serverAuth` (OID 1.3.6.1.5.5.7.3.1) | if !util.IsExtInCert(c, extKeyUsageOID) { return errResult("extKeyUsage extension is not present") } @@ -387,10 +382,10 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("extKeyUsage does not contain exactly id-kp-serverAuth") } - // keyUsage is "Critical, with only the digitalSignature (0) bit (and the - // keyEncipherment (2) bit, for RSA keys) set". We read the parenthetical - // as permitting, not requiring, keyEncipherment for RSA keys. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1089 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1089 + // |         `keyUsage` | Critical, with only the `digitalSignature` (0) bit (and the `keyEncipherment` (2) bit, for RSA keys) set | + // We read the parenthetical as permitting, not requiring, keyEncipherment + // for RSA keys. kuExt := getExtension(c, keyUsageOID) if kuExt == nil { return errResult("keyUsage extension is not present") @@ -410,9 +405,8 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("keyUsage asserts bits beyond digitalSignature (and keyEncipherment, for RSA keys)") } - // subjectAltName is "A sequence of 1 to 100 names of type dNSName or - // ipAddress (critical if CN omitted)". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1091 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1091 + // |         `subjectAltName` | A sequence of 1 to 100 names of type `dNSName` or `ipAddress` (critical if CN omitted) | sanExt := getExtension(c, subjectAltNameOID) if sanExt == nil { return errResult("subjectAltName extension is not present") @@ -432,9 +426,8 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("subjectAltName extension is critical despite the subject commonName being present") } - // subjectKeyIdentifier "Optionally contains a truncated hash of the - // subjectPublicKey, per Section 2(1) of RFC 7093". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1092 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1092 + // |         `subjectKeyIdentifier` | Optionally contains a truncated hash of the `subjectPublicKey`, per Section 2(1) of RFC 7093 | skidExt := getExtension(c, subjectKeyIdentifierOID) if skidExt != nil { if skidExt.Critical { @@ -454,9 +447,8 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes } } - // signatureAlgorithm is "Byte-for-byte identical to the - // tbsCertificate.signature". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1094 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1094 + // | `signatureAlgorithm` | Byte-for-byte identical to the `tbsCertificate.signature` | signatureAlgorithm, err := getOuterSignatureAlgorithm(c.Raw) if err != nil { return errResult(err.Error()) @@ -465,10 +457,10 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes return errResult("signatureAlgorithm is not byte-for-byte identical to the tbsCertificate.signature") } - // signatureValue is "A signature appropriate to the signatureAlgorithm - // field". We can't verify the signature here: pre-issuance linting - // operates on a certificate signed by a throwaway key. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1095 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1095 + // | `signatureValue` | A signature appropriate to the `signatureAlgorithm` field | + // We can't verify the signature here: pre-issuance linting operates on a + // certificate signed by a throwaway key. return nil } diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go index 92244342638..89c97397218 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -67,7 +67,8 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) CheckApplies(c *x509.Cert // Execute checks the given certificate against the TLS Subordinate CA // Certificate Profile, row by row. -// https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1041-L1066 +// https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1041 +// ### TLS Subordinate CA Certificate Profile func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { // Several rows of the profile require byte-for-byte correspondence with // the Issuing CA's certificate, so this lint must be configured with it. @@ -79,20 +80,19 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return fatalResult("lint has not been configured with the Issuing CA's certificate (issuer_certificate)") } - // version is "X.509 version 3" (Section 7.1.1). Note that unlike - // crypto/x509, zcrypto's Version field is one-indexed: it holds 3 (not the - // raw encoded value 2) for a v3 certificate. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1046 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1101-L1103 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1046 + // |     `version` | See [Section 7.1.1](#711-version-numbers) | + // Section 7.1.1 says "All certificates use X.509 version 3". Note that + // unlike crypto/x509, zcrypto's Version field is one-indexed: it holds 3 + // (not the raw encoded value 2) for a v3 certificate. if c.Version != 3 { return errResult("version is not v3") } - // serialNumber is "More than 100 bits of output from a CSPRNG, optionally - // with additional non-random bits". We can't test randomness here, but a - // serial containing more than 100 bits of CSPRNG output must itself be - // more than 100 bits long. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1047 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1047 + // |     `serialNumber` | More than 100 bits of output from a CSPRNG, optionally with additional non-random bits | + // We can't test randomness here, but a serial containing more than 100 + // bits of CSPRNG output must itself be more than 100 bits long. if c.SerialNumber == nil || c.SerialNumber.Sign() <= 0 { return errResult("serialNumber is not a positive integer") } @@ -100,11 +100,11 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("serialNumber is not more than 100 bits long") } - // signature is byte-for-byte identical to one of the hexadecimal encodings - // specified by Section 7.1.3.2 of the Baseline Requirements (Section - // 7.1.3.2). - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1048 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1115-L1117 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1048 + // |     `signature` | See [Section 7.1.3.2](#7132-signature-algorithmidentifier) | + // Section 7.1.3.2 requires signature AlgorithmIdentifiers to be + // byte-for-byte identical with one of the hexadecimal encodings specified + // by Section 7.1.3.2 of the Baseline Requirements. tbsSignature, err := util.GetSignatureAlgorithmInTBSEncoded(c) if err != nil { return errResult("failed to parse tbsCertificate.signature") @@ -113,17 +113,16 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") } - // issuer is "Byte-for-byte identical to the subject field of the Issuing - // CA". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1049 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1049 + // |     `issuer` | Byte-for-byte identical to the `subject` field of the Issuing CA | if !bytes.Equal(c.RawIssuer, issuer.RawSubject) { return errResult("issuer is not byte-for-byte identical to the subject of the configured Issuing CA") } - // validity is "At most 1098 days (approx. 3 years)". RFC 5280 4.1.2.5: - // "The validity period for a certificate is the period of time from - // notBefore through notAfter, inclusive." - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1050 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1050 + // |     `validity` | At most 1098 days (approx. 3 years) | + // RFC 5280 4.1.2.5: "The validity period for a certificate is the period + // of time from notBefore through notAfter, inclusive." if c.NotAfter.Before(c.NotBefore) { return errResult("validity is negative: notAfter is before notBefore") } @@ -131,9 +130,9 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("validity is more than 1098 days") } - // subject is "C=US, O=Let's Encrypt, and a unique CN". + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1051 + // |     `subject` | C=US, O=Let's Encrypt, and a unique CN | // We can't test for CN uniqueness here, but the rest we can check. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1051 if len(c.Subject.Names) != 3 { return errResult("subject does not contain exactly C, O, and CN attributes") } @@ -147,17 +146,15 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("subject commonName is empty") } - // subjectPublicKeyInfo: Section 6.1.5 says Subordinate CA key pairs are - // "either RSA keys whose encoded modulus size is 2048 bits, or ECDSA keys - // which are a valid point on the NIST P-384 elliptic curve", Section 6.1.6 - // requires the key parameter quality checks performed inline below, and - // Section 7.1.3.1 requires the AlgorithmIdentifier to be byte-for-byte - // identical to a BRs Section 7.1.3.1 encoding. Point-on-curve validation - // is also performed by zcrypto at parse time. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1052 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L854 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L858-L862 - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1111-L1113 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1052 + // |     `subjectPublicKeyInfo` | See Sections [6.1.5](#615-key-sizes), [6.1.6](#616-public-key-parameters-generation-and-quality-checking), and [7.1.3.1](#7131-subjectpublickeyinfo) | + // Section 6.1.5 says Subordinate CA key pairs are "either RSA keys whose + // encoded modulus size is 2048 bits, or ECDSA keys which are a valid + // point on the NIST P-384 elliptic curve". Section 6.1.6 requires the key + // parameter quality checks performed inline below. Section 7.1.3.1 + // requires the AlgorithmIdentifier to be byte-for-byte identical to a BRs + // Section 7.1.3.1 encoding. Point-on-curve validation is also performed + // by zcrypto at parse time. spkiAlgID, err := util.GetPublicKeyAidEncoded(c) if err != nil { return errResult("failed to parse subjectPublicKeyInfo algorithm") @@ -204,24 +201,24 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) } - // issuerUniqueID is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1053 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1053 + // |     `issuerUniqueID` | Not present | if c.IssuerUniqueId.Bytes != nil { return errResult("issuerUniqueID is present") } - // subjectUniqueID is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1054 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1054 + // |     `subjectUniqueID` | Not present | if c.SubjectUniqueId.Bytes != nil { return errResult("subjectUniqueID is present") } - // authorityInformationAccess "Contains the HTTP URI of the Issuing CA's - // Certificate". Whether the URI actually serves the Issuing CA's - // certificate is not observable here, but the extension must contain - // exactly one caIssuers entry with an http URI and nothing else (in - // particular, no OCSP entries). - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1056 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1056 + // |         `authorityInformationAccess` | Contains the HTTP URI of the Issuing CA's Certificate | + // Whether the URI actually serves the Issuing CA's certificate is not + // observable here, but the extension must contain exactly one caIssuers + // entry with an http URI and nothing else (in particular, no OCSP + // entries). if !util.IsExtInCert(c, authorityInformationAccessOID) { return errResult("authorityInformationAccess extension is not present") } @@ -240,9 +237,8 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("authorityInformationAccess caIssuers URI hostname is not a domain under a public suffix") } - // authorityKeyIdentifier "Contains a keyIdentifier byte-for-byte identical - // to the subjectKeyIdentifier of the Issuing CA". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1057 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1057 + // |         `authorityKeyIdentifier` | Contains a `keyIdentifier` byte-for-byte identical to the `subjectKeyIdentifier` of the Issuing CA | akidExt := getExtension(c, authorityKeyIdentifierOID) if akidExt == nil { return errResult("authorityKeyIdentifier extension is not present") @@ -257,9 +253,8 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("authorityKeyIdentifier keyIdentifier is not byte-for-byte identical to the subjectKeyIdentifier of the configured Issuing CA") } - // basicConstraints is "Critical, with cA set to true and pathLenConstraint - // set to 0". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1058 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1058 + // |         `basicConstraints` | Critical, with `cA` set to true and `pathLenConstraint` set to 0 | bcExt := getExtension(c, basicConstraintsOID) if bcExt == nil { return errResult("basicConstraints extension is not present") @@ -274,9 +269,8 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("basicConstraints pathLenConstraint is not 0") } - // certificatePolicies "Contains only the Baseline Requirements Domain - // Validated Reserved Policy Identifier (OID 2.23.140.1.2.1)". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1059 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1059 + // |         `certificatePolicies` | Contains only the Baseline Requirements Domain Validated Reserved Policy Identifier (OID 2.23.140.1.2.1) | if !util.IsExtInCert(c, certificatePoliciesOID) { return errResult("certificatePolicies extension is not present") } @@ -284,10 +278,10 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier") } - // crlDistributionPoints "Contains the HTTP URI of a CRL issued by the - // Issuing CA whose scope includes this certificate". Whether the CRL's - // scope actually includes this certificate is not observable here. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1060 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1060 + // |         `crlDistributionPoints` | Contains the HTTP URI of a CRL issued by the Issuing CA whose scope includes this certificate | + // Whether the CRL's scope actually includes this certificate is not + // observable here. if !util.IsExtInCert(c, crlDistributionPointsOID) { return errResult("crlDistributionPoints extension is not present") } @@ -303,8 +297,8 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("crlDistributionPoints URI hostname is not a domain under a public suffix") } - // extKeyUsage "Contains only id-kp-serverAuth (OID 1.3.6.1.5.5.7.3.1)". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1061 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1061 + // |         `extKeyUsage` | Contains only `id-kp-serverAuth` (OID 1.3.6.1.5.5.7.3.1) | if !util.IsExtInCert(c, extKeyUsageOID) { return errResult("extKeyUsage extension is not present") } @@ -312,9 +306,8 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("extKeyUsage does not contain exactly id-kp-serverAuth") } - // keyUsage is "Critical, with only the digitalSignature (0), keyCertSign - // (5), and cRLSign (6) bits set". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1062 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1062 + // |         `keyUsage` | Critical, with only the `digitalSignature` (0), `keyCertSign` (5), and `cRLSign` (6) bits set | kuExt := getExtension(c, keyUsageOID) if kuExt == nil { return errResult("keyUsage extension is not present") @@ -326,9 +319,8 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("keyUsage does not assert exactly the bits required by the profile") } - // subjectKeyIdentifier "Contains a truncated hash of the subjectPublicKey, - // per Section 2(1) of RFC 7093". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1063 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1063 + // |         `subjectKeyIdentifier` | Contains a truncated hash of the `subjectPublicKey`, per Section 2(1) of RFC 7093 | skidExt := getExtension(c, subjectKeyIdentifierOID) if skidExt == nil { return errResult("subjectKeyIdentifier extension is not present") @@ -349,8 +341,8 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("subjectKeyIdentifier is not the RFC 7093 Section 2(1) truncated hash of the subjectPublicKey") } - // Any other extension is "Not present". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1064 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1064 + // |         Any other extension | Not present | allowedExtensions := []asn1.ObjectIdentifier{ authorityInformationAccessOID, authorityKeyIdentifierOID, @@ -373,9 +365,8 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica } } - // signatureAlgorithm is "Byte-for-byte identical to the - // tbsCertificate.signature". - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1065 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1065 + // | `signatureAlgorithm` | Byte-for-byte identical to the `tbsCertificate.signature` | signatureAlgorithm, err := getOuterSignatureAlgorithm(c.Raw) if err != nil { return errResult(err.Error()) @@ -384,10 +375,10 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica return errResult("signatureAlgorithm is not byte-for-byte identical to the tbsCertificate.signature") } - // signatureValue is "A signature appropriate to the signatureAlgorithm - // field". We can't verify the signature here: pre-issuance linting - // operates on a certificate signed by a throwaway key. - // https://github.com/letsencrypt/cp-cps/blob/6adcd83ff21e9571a39339048364edd6ba34ed39/CP-CPS.md?plain=1#L1066 + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1066 + // | `signatureValue` | A signature appropriate to the `signatureAlgorithm` field | + // We can't verify the signature here: pre-issuance linting operates on a + // certificate signed by a throwaway key. return &lint.LintResult{Status: lint.Pass} } From 085dcaa5a23528dcfa7da198e2abb7b191def3a0 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Fri, 24 Jul 2026 13:27:39 -0700 Subject: [PATCH 13/18] Review comments --- linter/lints/cpcps/helpers.go | 2 +- linter/lints/cpcps/helpers_test.go | 59 +++++++++++ ...ss_certified_subordinate_ca_certificate.go | 70 ++++++++---- ...rtified_subordinate_ca_certificate_test.go | 58 ++++++++++ linter/lints/cpcps/lint_precertificate.go | 43 ++++---- .../lints/cpcps/lint_precertificate_test.go | 9 ++ .../lints/cpcps/lint_root_ca_certificate.go | 36 ++++--- .../cpcps/lint_root_ca_certificate_test.go | 8 ++ .../lint_subscriber_server_certificate.go | 100 +++++++++++------- ...lint_subscriber_server_certificate_test.go | 87 +++++++++++++++ .../lint_tls_subordinate_ca_certificate.go | 70 ++++++++---- ...int_tls_subordinate_ca_certificate_test.go | 57 ++++++++++ 12 files changed, 481 insertions(+), 118 deletions(-) diff --git a/linter/lints/cpcps/helpers.go b/linter/lints/cpcps/helpers.go index df62423d223..8d225134356 100644 --- a/linter/lints/cpcps/helpers.go +++ b/linter/lints/cpcps/helpers.go @@ -69,7 +69,7 @@ var ( ) // Keys within the shared configuration stanza. These must match the toml -// tags on IssuingCAConfig's fields, and are exported so that the linter +// tags on SharedConfig's fields, and are exported so that the linter // package can emit configuration using these keys. const ( // GlobalConfigNamespace is the name of the TOML stanza from which diff --git a/linter/lints/cpcps/helpers_test.go b/linter/lints/cpcps/helpers_test.go index 663c9543e34..7d90c85e633 100644 --- a/linter/lints/cpcps/helpers_test.go +++ b/linter/lints/cpcps/helpers_test.go @@ -205,6 +205,65 @@ func testLeafTemplate(t *testing.T) *x509.Certificate { } } +// synthesizeExt returns a default version of the extension with the given OID. +// It does so by creating a throwaway cert from the given template, getting +// crypto/x509 to produce the extension for us, and then extracts the bits we +// want from the resulting cert. This is useful for creating extensions that Go +// produces from typed fields, like x509.Certificate.CRLDistributionPoints +// making the CRLDP extension. +func synthesizeExt(t *testing.T, tmpl *x509.Certificate, oid asn1.ObjectIdentifier) pkix.Extension { + t.Helper() + key := testKey(t, elliptic.P256()) + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + t.Fatalf("creating throwaway certificate: %s", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parsing throwaway certificate: %s", err) + } + for _, ext := range cert.Extensions { + if ext.Id.Equal(oid) { + return ext + } + } + t.Fatalf("template did not produce extension %s", oid) + return pkix.Extension{} +} + +// criticalizeExt marks the template's extension with the given OID as critical. +// If the extension is already present in ExtraExtensions, its Critical bit is +// set directly. Otherwise, the extension value CreateCertificate would +// produce is harvested and re-added as a critical ExtraExtension, which +// overrides the non-critical extension CreateCertificate would otherwise +// generate. +func criticalizeExt(t *testing.T, tmpl *x509.Certificate, oid asn1.ObjectIdentifier) { + t.Helper() + for i := range tmpl.ExtraExtensions { + if tmpl.ExtraExtensions[i].Id.Equal(oid) { + tmpl.ExtraExtensions[i].Critical = true + return + } + } + ext := synthesizeExt(t, tmpl, oid) + ext.Critical = true + tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, ext) +} + +// duplicateExt causes the template to produce two identical copies of +// the extension with the given OID. +func duplicateExt(t *testing.T, tmpl *x509.Certificate, oid asn1.ObjectIdentifier) { + t.Helper() + for i := range tmpl.ExtraExtensions { + if tmpl.ExtraExtensions[i].Id.Equal(oid) { + tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, tmpl.ExtraExtensions[i]) + return + } + } + ext := synthesizeExt(t, tmpl, oid) + tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, ext, ext) +} + // testPEM returns the PEM encoding of the given certificate DER. func testPEM(t *testing.T, der []byte) string { t.Helper() diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go index 78939dcbd0e..077e8270f00 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -11,7 +11,6 @@ import ( "time" "github.com/weppos/publicsuffix-go/publicsuffix" - "github.com/zmap/zcrypto/encoding/asn1" zrsa "github.com/zmap/zcrypto/rsa" "github.com/zmap/zcrypto/x509" "github.com/zmap/zlint/v3/lint" @@ -158,8 +157,11 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 } switch key := c.PublicKey.(type) { case *zrsa.PublicKey: - if key.N.BitLen() != 4096 { - return errResult(fmt.Sprintf("RSA modulus size %d is not allowed", key.N.BitLen())) + // DER INTEGERs are minimal-length, so the encoded modulus size is its + // bit length rounded up to a whole number of octets. + encodedModulusBits := len(key.N.Bytes()) * 8 + if encodedModulusBits != 4096 { + return errResult(fmt.Sprintf("RSA encoded modulus size %d is not allowed", encodedModulusBits)) } if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") @@ -167,6 +169,7 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // Section 6.1.6, via NIST SP 800-89 Section 5.3.3, requires that RSA // keys have "a public exponent of 65537 and an odd modulus which has // no factors smaller than 752". + // https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-89.pdf if key.E.Cmp(big.NewInt(65537)) != 0 { return errResult(fmt.Sprintf("RSA public exponent %s is not 65537", key.E)) } @@ -190,6 +193,7 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // underlying field, and not the point at infinity; the routine's // final step, confirming the point's order, is implied by the others // for the NIST curves, whose cofactors are 1. + // https://nvlpubs.nist.gov/nistpubs/specialpublications/nist.sp.800-56ar2.pdf _, err = ecdh.P384().NewPublicKey(key.Raw.Bytes) if err != nil { return errResult("ECDSA public key is not a valid uncompressed point on its curve") @@ -216,9 +220,13 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // observable here, but the extension must contain exactly one caIssuers // entry with an http URI and nothing else (in particular, no OCSP // entries). - if !util.IsExtInCert(c, authorityInformationAccessOID) { + aiaExt := getExtension(c, authorityInformationAccessOID) + if aiaExt == nil { return errResult("authorityInformationAccess extension is not present") } + if aiaExt.Critical { + return errResult("authorityInformationAccess extension is critical") + } if len(c.OCSPServer) != 0 { return errResult("authorityInformationAccess contains an OCSP entry") } @@ -268,9 +276,13 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1032 // |         `certificatePolicies` | Contains only the Baseline Requirements Domain Validated Reserved Policy Identifier (OID 2.23.140.1.2.1) | - if !util.IsExtInCert(c, certificatePoliciesOID) { + cpExt := getExtension(c, certificatePoliciesOID) + if cpExt == nil { return errResult("certificatePolicies extension is not present") } + if cpExt.Critical { + return errResult("certificatePolicies extension is critical") + } if len(c.PolicyIdentifiers) != 1 || !c.PolicyIdentifiers[0].Equal(domainValidatedOID) { return errResult("certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier") } @@ -279,9 +291,13 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // |         `crlDistributionPoints` | Contains the HTTP URI of a CRL issued by the Issuing CA whose scope includes this certificate | // Whether the CRL's scope actually includes this certificate is not // observable here. - if !util.IsExtInCert(c, crlDistributionPointsOID) { + crldpExt := getExtension(c, crlDistributionPointsOID) + if crldpExt == nil { return errResult("crlDistributionPoints extension is not present") } + if crldpExt.Critical { + return errResult("crlDistributionPoints extension is critical") + } if len(c.CRLDistributionPoints) != 1 { return errResult("crlDistributionPoints does not contain exactly one distribution point") } @@ -296,9 +312,13 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1034 // |         `extKeyUsage` | Contains only `id-kp-serverAuth` (OID 1.3.6.1.5.5.7.3.1) | - if !util.IsExtInCert(c, extKeyUsageOID) { + ekuExt := getExtension(c, extKeyUsageOID) + if ekuExt == nil { return errResult("extKeyUsage extension is not present") } + if ekuExt.Critical { + return errResult("extKeyUsage extension is critical") + } if len(c.ExtKeyUsage) != 1 || c.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth || len(c.UnknownExtKeyUsage) != 0 { return errResult("extKeyUsage does not contain exactly id-kp-serverAuth") } @@ -337,26 +357,30 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1037 // |         Any other extension | Not present | - allowedExtensions := []asn1.ObjectIdentifier{ - authorityInformationAccessOID, - authorityKeyIdentifierOID, - basicConstraintsOID, - certificatePoliciesOID, - crlDistributionPointsOID, - extKeyUsageOID, - keyUsageOID, - subjectKeyIdentifierOID, + extensions := map[string]bool{ + authorityInformationAccessOID.String(): false, + authorityKeyIdentifierOID.String(): false, + basicConstraintsOID.String(): false, + certificatePoliciesOID.String(): false, + crlDistributionPointsOID.String(): false, + extKeyUsageOID.String(): false, + keyUsageOID.String(): false, + subjectKeyIdentifierOID.String(): false, } for _, ext := range c.Extensions { - found := false - for _, oid := range allowedExtensions { - if ext.Id.Equal(oid) { - found = true - } - } - if !found { + seen, allowed := extensions[ext.Id.String()] + if !allowed { return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) } + if seen { + return errResult(fmt.Sprintf("duplicate extension %s", ext.Id.String())) + } + extensions[ext.Id.String()] = true + } + for oid, seen := range extensions { + if !seen { + return errResult(fmt.Sprintf("missing extension %s", oid)) + } } // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1038 diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go index f46853d0af0..2b9cf109493 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go @@ -7,6 +7,7 @@ import ( "crypto/rsa" "crypto/x509" "crypto/x509/pkix" + "encoding/asn1" "math/big" "strings" "testing" @@ -229,6 +230,63 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "RSA modulus has a prime factor smaller than 752", }, + { + // 2^4094 has a bit length of 4095, but still encodes in 512 + // octets, so its encoded modulus size is 4096 bits: it passes the + // size check and fails the parity check instead. + name: "rsa_modulus_leading_zero_bit", + pub: &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 4094), E: 65537}, + want: lint.Error, + wantSubStr: "RSA modulus is even", + }, + { + // 2^4087 encodes in 511 octets, so its encoded modulus size is + // 4088 bits. + name: "rsa_modulus_wrong_encoded_size", + pub: &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 4087), E: 65537}, + want: lint.Error, + wantSubStr: "RSA encoded modulus size 4088 is not allowed", + }, + { + name: "critical_aia", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(authorityInformationAccessOID)) + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess extension is critical", + }, + { + name: "critical_certificate_policies", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(certificatePoliciesOID)) + }, + want: lint.Error, + wantSubStr: "certificatePolicies extension is critical", + }, + { + name: "critical_crldp", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(crlDistributionPointsOID)) + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints extension is critical", + }, + { + name: "critical_eku", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(extKeyUsageOID)) + }, + want: lint.Error, + wantSubStr: "extKeyUsage extension is critical", + }, + { + name: "duplicate_extension", + mod: func(t *testing.T, tmpl *x509.Certificate) { + duplicateExt(t, tmpl, asn1.ObjectIdentifier(keyUsageOID)) + }, + want: lint.Error, + wantSubStr: "duplicate extension 2.5.29.15", + }, } for _, tc := range testCases { diff --git a/linter/lints/cpcps/lint_precertificate.go b/linter/lints/cpcps/lint_precertificate.go index acf8b9af7e6..a39da2f79e8 100644 --- a/linter/lints/cpcps/lint_precertificate.go +++ b/linter/lints/cpcps/lint_precertificate.go @@ -3,7 +3,6 @@ package cpcps import ( "fmt" - "github.com/zmap/zcrypto/encoding/asn1" "github.com/zmap/zcrypto/x509" "github.com/zmap/zlint/v3/lint" "github.com/zmap/zlint/v3/util" @@ -73,29 +72,33 @@ func (l *precertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.Lin // precertificates and replaced by the CT poison extension, so the allowed // set here differs from the Subscriber (Server) Certificate Profile's by // exactly that substitution. - allowedExtensions := []asn1.ObjectIdentifier{ - authorityInformationAccessOID, - authorityKeyIdentifierOID, - basicConstraintsOID, - certificatePoliciesOID, - crlDistributionPointsOID, - extKeyUsageOID, - keyUsageOID, - util.CtPoisonOID, - subjectAltNameOID, - subjectKeyIdentifierOID, + extensions := map[string]bool{ + authorityInformationAccessOID.String(): false, + authorityKeyIdentifierOID.String(): false, + basicConstraintsOID.String(): false, + certificatePoliciesOID.String(): false, + crlDistributionPointsOID.String(): false, + extKeyUsageOID.String(): false, + keyUsageOID.String(): false, + util.CtPoisonOID.String(): false, + subjectAltNameOID.String(): false, + subjectKeyIdentifierOID.String(): false, } for _, ext := range c.Extensions { - found := false - for _, oid := range allowedExtensions { - if ext.Id.Equal(oid) { - found = true - } - } - if !found { + seen, allowed := extensions[ext.Id.String()] + if !allowed { return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) } + if seen { + return errResult(fmt.Sprintf("duplicate extension %s", ext.Id.String())) + } + extensions[ext.Id.String()] = true + } + for oid, seen := range extensions { + // The subjectKeyIdentifier extension is optional, so missing it is ok. + if !seen && oid != subjectKeyIdentifierOID.String() { + return errResult(fmt.Sprintf("missing extension %s", oid)) + } } - return &lint.LintResult{Status: lint.Pass} } diff --git a/linter/lints/cpcps/lint_precertificate_test.go b/linter/lints/cpcps/lint_precertificate_test.go index 5711afb327f..0f3543b819f 100644 --- a/linter/lints/cpcps/lint_precertificate_test.go +++ b/linter/lints/cpcps/lint_precertificate_test.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/x509" "crypto/x509/pkix" + "encoding/asn1" "math/big" "strings" "testing" @@ -89,6 +90,14 @@ func TestPrecertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "serialNumber is not more than 100 bits long", }, + { + name: "duplicate_extension", + mod: func(t *testing.T, tmpl *x509.Certificate) { + duplicateExt(t, tmpl, asn1.ObjectIdentifier(keyUsageOID)) + }, + want: lint.Error, + wantSubStr: "duplicate extension 2.5.29.15", + }, } for _, tc := range testCases { diff --git a/linter/lints/cpcps/lint_root_ca_certificate.go b/linter/lints/cpcps/lint_root_ca_certificate.go index 1fdd3424834..bb96ce27487 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate.go +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -10,7 +10,6 @@ import ( "math/big" "time" - "github.com/zmap/zcrypto/encoding/asn1" zrsa "github.com/zmap/zcrypto/rsa" "github.com/zmap/zcrypto/x509" "github.com/zmap/zlint/v3/lint" @@ -131,8 +130,11 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. } switch key := c.PublicKey.(type) { case *zrsa.PublicKey: - if key.N.BitLen() != 4096 { - return errResult(fmt.Sprintf("RSA modulus size %d is not allowed", key.N.BitLen())) + // DER INTEGERs are minimal-length, so the encoded modulus size is its + // bit length rounded up to a whole number of octets. + encodedModulusBits := len(key.N.Bytes()) * 8 + if encodedModulusBits != 4096 { + return errResult(fmt.Sprintf("RSA encoded modulus size %d is not allowed", encodedModulusBits)) } if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") @@ -140,6 +142,7 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. // Section 6.1.6, via NIST SP 800-89 Section 5.3.3, requires that RSA // keys have "a public exponent of 65537 and an odd modulus which has // no factors smaller than 752". + // https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-89.pdf if key.E.Cmp(big.NewInt(65537)) != 0 { return errResult(fmt.Sprintf("RSA public exponent %s is not 65537", key.E)) } @@ -163,6 +166,7 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. // underlying field, and not the point at infinity; the routine's // final step, confirming the point's order, is implied by the others // for the NIST curves, whose cofactors are 1. + // https://nvlpubs.nist.gov/nistpubs/specialpublications/nist.sp.800-56ar2.pdf _, err = ecdh.P384().NewPublicKey(key.Raw.Bytes) if err != nil { return errResult("ECDSA public key is not a valid uncompressed point on its curve") @@ -233,21 +237,25 @@ func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint. // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1010 // |         Any other extension | Not present | - allowedExtensions := []asn1.ObjectIdentifier{ - basicConstraintsOID, - keyUsageOID, - subjectKeyIdentifierOID, + extensions := map[string]bool{ + basicConstraintsOID.String(): false, + keyUsageOID.String(): false, + subjectKeyIdentifierOID.String(): false, } for _, ext := range c.Extensions { - found := false - for _, oid := range allowedExtensions { - if ext.Id.Equal(oid) { - found = true - } - } - if !found { + seen, allowed := extensions[ext.Id.String()] + if !allowed { return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) } + if seen { + return errResult(fmt.Sprintf("duplicate extension %s", ext.Id.String())) + } + extensions[ext.Id.String()] = true + } + for oid, seen := range extensions { + if !seen { + return errResult(fmt.Sprintf("missing extension %s", oid)) + } } // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1011 diff --git a/linter/lints/cpcps/lint_root_ca_certificate_test.go b/linter/lints/cpcps/lint_root_ca_certificate_test.go index 261286b47d9..5b214d77706 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_root_ca_certificate_test.go @@ -141,6 +141,14 @@ func TestRootCACertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "unexpected extension", }, + { + name: "duplicate_extension", + mod: func(t *testing.T, tmpl *x509.Certificate) { + duplicateExt(t, tmpl, asn1.ObjectIdentifier(keyUsageOID)) + }, + want: lint.Error, + wantSubStr: "duplicate extension 2.5.29.15", + }, } for _, tc := range testCases { diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate.go b/linter/lints/cpcps/lint_subscriber_server_certificate.go index 395e9c36dbe..468e8a9eeb7 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -14,7 +14,6 @@ import ( "time" "github.com/weppos/publicsuffix-go/publicsuffix" - "github.com/zmap/zcrypto/encoding/asn1" zrsa "github.com/zmap/zcrypto/rsa" "github.com/zmap/zcrypto/x509" "github.com/zmap/zcrypto/x509/ct" @@ -74,9 +73,13 @@ func (l *subscriberServerCertificateMatchesCPSProfile) Execute(c *x509.Certifica // We can't map log IDs to operators here, but SCTs from different // operators necessarily come from different logs, so we can check that at // least two distinct logs are present. - if !util.IsExtInCert(c, sctListOID) { + sctExt := getExtension(c, sctListOID) + if sctExt == nil { return errResult("signedCertificateTimestampList extension is not present") } + if sctExt.Critical { + return errResult("signedCertificateTimestampList extension is critical") + } logIDs := make(map[ct.SHA256Hash]bool) for _, sct := range c.SignedCertificateTimestampList { logIDs[sct.LogID] = true @@ -86,31 +89,35 @@ func (l *subscriberServerCertificateMatchesCPSProfile) Execute(c *x509.Certifica } // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1093 - // |         Any other extension | Not present | - allowedExtensions := []asn1.ObjectIdentifier{ - authorityInformationAccessOID, - authorityKeyIdentifierOID, - basicConstraintsOID, - certificatePoliciesOID, - crlDistributionPointsOID, - extKeyUsageOID, - keyUsageOID, - sctListOID, - subjectAltNameOID, - subjectKeyIdentifierOID, + // |         Any other extensions | Not present | + extensions := map[string]bool{ + authorityInformationAccessOID.String(): false, + authorityKeyIdentifierOID.String(): false, + basicConstraintsOID.String(): false, + certificatePoliciesOID.String(): false, + crlDistributionPointsOID.String(): false, + extKeyUsageOID.String(): false, + keyUsageOID.String(): false, + sctListOID.String(): false, + subjectAltNameOID.String(): false, + subjectKeyIdentifierOID.String(): false, } for _, ext := range c.Extensions { - found := false - for _, oid := range allowedExtensions { - if ext.Id.Equal(oid) { - found = true - } - } - if !found { + seen, allowed := extensions[ext.Id.String()] + if !allowed { return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) } + if seen { + return errResult(fmt.Sprintf("duplicate extension %s", ext.Id.String())) + } + extensions[ext.Id.String()] = true + } + for oid, seen := range extensions { + // The subjectKeyIdentifier extension is optional, so missing it is ok. + if !seen && oid != subjectKeyIdentifierOID.String() { + return errResult(fmt.Sprintf("missing extension %s", oid)) + } } - return &lint.LintResult{Status: lint.Pass} } @@ -188,15 +195,15 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1078 // |     `subject` | CN omitted, or optionally contains one of the values from the Subject Alternative Name extension | - // Per Section 7.1.4, no other subject attributes are included in - // Subscriber Certificates. for _, atv := range c.Subject.Names { if !atv.Type.Equal(commonNameOID) { return errResult(fmt.Sprintf("subject contains an attribute other than commonName: %s", atv.Type.String())) } - } - if c.Subject.CommonName != "" { - cnIP := net.ParseIP(c.Subject.CommonName) + cn, ok := atv.Value.(string) + if !ok { + return errResult("subject commonName value is not a string") + } + cnIP := net.ParseIP(cn) if cnIP != nil { found := false for _, ip := range c.IPAddresses { @@ -207,7 +214,7 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes if !found { return errResult("subject commonName is not one of the subjectAltName ipAddress values") } - } else if !slices.Contains(c.DNSNames, c.Subject.CommonName) { + } else if !slices.Contains(c.DNSNames, cn) { return errResult("subject commonName is not one of the subjectAltName dNSName values") } } @@ -228,8 +235,11 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes } switch key := c.PublicKey.(type) { case *zrsa.PublicKey: - if key.N.BitLen() != 2048 && key.N.BitLen() != 3072 && key.N.BitLen() != 4096 { - return errResult(fmt.Sprintf("RSA modulus size %d is not allowed", key.N.BitLen())) + // DER INTEGERs are minimal-length, so the encoded modulus size is its + // bit length rounded up to a whole number of octets. + encodedModulusBits := len(key.N.Bytes()) * 8 + if encodedModulusBits != 2048 && encodedModulusBits != 3072 && encodedModulusBits != 4096 { + return errResult(fmt.Sprintf("RSA encoded modulus size %d is not allowed", encodedModulusBits)) } if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") @@ -237,6 +247,7 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // Section 6.1.6, via NIST SP 800-89 Section 5.3.3, requires that RSA // keys have "a public exponent of 65537 and an odd modulus which has // no factors smaller than 752". + // https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-89.pdf if key.E.Cmp(big.NewInt(65537)) != 0 { return errResult(fmt.Sprintf("RSA public exponent %s is not 65537", key.E)) } @@ -272,6 +283,7 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // underlying field, and not the point at infinity; the routine's // final step, confirming the point's order, is implied by the others // for the NIST curves, whose cofactors are 1. + // https://nvlpubs.nist.gov/nistpubs/specialpublications/nist.sp.800-56ar2.pdf _, err = ecdhCurve.NewPublicKey(key.Raw.Bytes) if err != nil { return errResult("ECDSA public key is not a valid uncompressed point on its curve") @@ -298,9 +310,13 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // observable here, but the extension must contain exactly one caIssuers // entry with an http URI and nothing else (in particular, no OCSP // entries). - if !util.IsExtInCert(c, authorityInformationAccessOID) { + aiaExt := getExtension(c, authorityInformationAccessOID) + if aiaExt == nil { return errResult("authorityInformationAccess extension is not present") } + if aiaExt.Critical { + return errResult("authorityInformationAccess extension is critical") + } if len(c.OCSPServer) != 0 { return errResult("authorityInformationAccess contains an OCSP entry") } @@ -347,9 +363,13 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1086 // |         `certificatePolicies` | Contains only the Baseline Requirements Domain Validated Reserved Policy Identifier (OID 2.23.140.1.2.1) | - if !util.IsExtInCert(c, certificatePoliciesOID) { + cpExt := getExtension(c, certificatePoliciesOID) + if cpExt == nil { return errResult("certificatePolicies extension is not present") } + if cpExt.Critical { + return errResult("certificatePolicies extension is critical") + } if len(c.PolicyIdentifiers) != 1 || !c.PolicyIdentifiers[0].Equal(domainValidatedOID) { return errResult("certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier") } @@ -358,9 +378,13 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // |         `crlDistributionPoints` | Contains the HTTP URI of a CRL issued by the Issuing CA whose scope includes this certificate | // Whether the CRL's scope actually includes this certificate is not // observable here. - if !util.IsExtInCert(c, crlDistributionPointsOID) { + crldpExt := getExtension(c, crlDistributionPointsOID) + if crldpExt == nil { return errResult("crlDistributionPoints extension is not present") } + if crldpExt.Critical { + return errResult("crlDistributionPoints extension is critical") + } if len(c.CRLDistributionPoints) != 1 { return errResult("crlDistributionPoints does not contain exactly one distribution point") } @@ -375,17 +399,19 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1088 // |         `extKeyUsage` | Contains only `id-kp-serverAuth` (OID 1.3.6.1.5.5.7.3.1) | - if !util.IsExtInCert(c, extKeyUsageOID) { + ekuExt := getExtension(c, extKeyUsageOID) + if ekuExt == nil { return errResult("extKeyUsage extension is not present") } + if ekuExt.Critical { + return errResult("extKeyUsage extension is critical") + } if len(c.ExtKeyUsage) != 1 || c.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth || len(c.UnknownExtKeyUsage) != 0 { return errResult("extKeyUsage does not contain exactly id-kp-serverAuth") } // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1089 - // |         `keyUsage` | Critical, with only the `digitalSignature` (0) bit (and the `keyEncipherment` (2) bit, for RSA keys) set | - // We read the parenthetical as permitting, not requiring, keyEncipherment - // for RSA keys. + // |         `keyUsage` | Critical, with only the `digitalSignature` (0) bit (and optionally the `keyEncipherment` (2) bit, for RSA keys) set | kuExt := getExtension(c, keyUsageOID) if kuExt == nil { return errResult("keyUsage extension is not present") diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go index d6f3c7e7b8f..59bc677b52c 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go @@ -120,6 +120,28 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "subject contains an attribute other than commonName", }, + { + name: "cn_not_a_string", + mod: func(t *testing.T, tmpl *x509.Certificate) { + // A subject whose commonName value is an OCTET STRING rather + // than any ASN.1 string type. RawSubject is used verbatim by + // CreateCertificate, overriding the Subject field. + rawSubject, err := asn1.Marshal(pkix.RDNSequence{ + pkix.RelativeDistinguishedNameSET{ + pkix.AttributeTypeAndValue{ + Type: asn1.ObjectIdentifier{2, 5, 4, 3}, + Value: []byte("example.com"), + }, + }, + }) + if err != nil { + t.Fatalf("marshalling subject: %s", err) + } + tmpl.RawSubject = rawSubject + }, + want: lint.Error, + wantSubStr: "subject commonName value is not a string", + }, { name: "email_san", mod: func(t *testing.T, tmpl *x509.Certificate) { @@ -282,6 +304,71 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "RSA modulus has a prime factor smaller than 752", }, + { + // 2^2046 has a bit length of 2047, but still encodes in 256 + // octets, so its encoded modulus size is 2048 bits: it passes the + // size check and fails the parity check instead. + name: "rsa_modulus_leading_zero_bit", + pub: &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 2046), E: 65537}, + want: lint.Error, + wantSubStr: "RSA modulus is even", + }, + { + // 2^2039 encodes in 255 octets, so its encoded modulus size is + // 2040 bits. + name: "rsa_modulus_wrong_encoded_size", + pub: &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 2039), E: 65537}, + want: lint.Error, + wantSubStr: "RSA encoded modulus size 2040 is not allowed", + }, + { + name: "critical_aia", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(authorityInformationAccessOID)) + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess extension is critical", + }, + { + name: "critical_certificate_policies", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(certificatePoliciesOID)) + }, + want: lint.Error, + wantSubStr: "certificatePolicies extension is critical", + }, + { + name: "critical_crldp", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(crlDistributionPointsOID)) + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints extension is critical", + }, + { + name: "critical_eku", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(extKeyUsageOID)) + }, + want: lint.Error, + wantSubStr: "extKeyUsage extension is critical", + }, + { + name: "critical_sct_list", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(sctListOID)) + }, + want: lint.Error, + wantSubStr: "signedCertificateTimestampList extension is critical", + }, + { + name: "duplicate_extension", + mod: func(t *testing.T, tmpl *x509.Certificate) { + duplicateExt(t, tmpl, asn1.ObjectIdentifier(keyUsageOID)) + }, + want: lint.Error, + wantSubStr: "duplicate extension 2.5.29.15", + }, { name: "missing_crldp", mod: func(t *testing.T, tmpl *x509.Certificate) { diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go index 89c97397218..86db83228cd 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -12,7 +12,6 @@ import ( "time" "github.com/weppos/publicsuffix-go/publicsuffix" - "github.com/zmap/zcrypto/encoding/asn1" zrsa "github.com/zmap/zcrypto/rsa" "github.com/zmap/zcrypto/x509" "github.com/zmap/zlint/v3/lint" @@ -161,8 +160,11 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica } switch key := c.PublicKey.(type) { case *zrsa.PublicKey: - if key.N.BitLen() != 2048 { - return errResult(fmt.Sprintf("RSA modulus size %d is not allowed", key.N.BitLen())) + // DER INTEGERs are minimal-length, so the encoded modulus size is its + // bit length rounded up to a whole number of octets. + encodedModulusBits := len(key.N.Bytes()) * 8 + if encodedModulusBits != 2048 { + return errResult(fmt.Sprintf("RSA encoded modulus size %d is not allowed", encodedModulusBits)) } if hex.EncodeToString(spkiAlgID) != spkiAlgorithmRSA { return errResult("public key algorithm is not byte-for-byte identical to the BRs Section 7.1.3.1 RSA encoding") @@ -170,6 +172,7 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // Section 6.1.6, via NIST SP 800-89 Section 5.3.3, requires that RSA // keys have "a public exponent of 65537 and an odd modulus which has // no factors smaller than 752". + // https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-89.pdf if key.E.Cmp(big.NewInt(65537)) != 0 { return errResult(fmt.Sprintf("RSA public exponent %s is not 65537", key.E)) } @@ -193,6 +196,7 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // underlying field, and not the point at infinity; the routine's // final step, confirming the point's order, is implied by the others // for the NIST curves, whose cofactors are 1. + // https://nvlpubs.nist.gov/nistpubs/specialpublications/nist.sp.800-56ar2.pdf _, err = ecdh.P384().NewPublicKey(key.Raw.Bytes) if err != nil { return errResult("ECDSA public key is not a valid uncompressed point on its curve") @@ -219,9 +223,13 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // observable here, but the extension must contain exactly one caIssuers // entry with an http URI and nothing else (in particular, no OCSP // entries). - if !util.IsExtInCert(c, authorityInformationAccessOID) { + aiaExt := getExtension(c, authorityInformationAccessOID) + if aiaExt == nil { return errResult("authorityInformationAccess extension is not present") } + if aiaExt.Critical { + return errResult("authorityInformationAccess extension is critical") + } if len(c.OCSPServer) != 0 { return errResult("authorityInformationAccess contains an OCSP entry") } @@ -271,9 +279,13 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1059 // |         `certificatePolicies` | Contains only the Baseline Requirements Domain Validated Reserved Policy Identifier (OID 2.23.140.1.2.1) | - if !util.IsExtInCert(c, certificatePoliciesOID) { + cpExt := getExtension(c, certificatePoliciesOID) + if cpExt == nil { return errResult("certificatePolicies extension is not present") } + if cpExt.Critical { + return errResult("certificatePolicies extension is critical") + } if len(c.PolicyIdentifiers) != 1 || !c.PolicyIdentifiers[0].Equal(domainValidatedOID) { return errResult("certificatePolicies does not contain exactly the Domain Validated Reserved Policy Identifier") } @@ -282,9 +294,13 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // |         `crlDistributionPoints` | Contains the HTTP URI of a CRL issued by the Issuing CA whose scope includes this certificate | // Whether the CRL's scope actually includes this certificate is not // observable here. - if !util.IsExtInCert(c, crlDistributionPointsOID) { + crldpExt := getExtension(c, crlDistributionPointsOID) + if crldpExt == nil { return errResult("crlDistributionPoints extension is not present") } + if crldpExt.Critical { + return errResult("crlDistributionPoints extension is critical") + } if len(c.CRLDistributionPoints) != 1 { return errResult("crlDistributionPoints does not contain exactly one distribution point") } @@ -299,9 +315,13 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1061 // |         `extKeyUsage` | Contains only `id-kp-serverAuth` (OID 1.3.6.1.5.5.7.3.1) | - if !util.IsExtInCert(c, extKeyUsageOID) { + ekuExt := getExtension(c, extKeyUsageOID) + if ekuExt == nil { return errResult("extKeyUsage extension is not present") } + if ekuExt.Critical { + return errResult("extKeyUsage extension is critical") + } if len(c.ExtKeyUsage) != 1 || c.ExtKeyUsage[0] != x509.ExtKeyUsageServerAuth || len(c.UnknownExtKeyUsage) != 0 { return errResult("extKeyUsage does not contain exactly id-kp-serverAuth") } @@ -343,26 +363,30 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1064 // |         Any other extension | Not present | - allowedExtensions := []asn1.ObjectIdentifier{ - authorityInformationAccessOID, - authorityKeyIdentifierOID, - basicConstraintsOID, - certificatePoliciesOID, - crlDistributionPointsOID, - extKeyUsageOID, - keyUsageOID, - subjectKeyIdentifierOID, + extensions := map[string]bool{ + authorityInformationAccessOID.String(): false, + authorityKeyIdentifierOID.String(): false, + basicConstraintsOID.String(): false, + certificatePoliciesOID.String(): false, + crlDistributionPointsOID.String(): false, + extKeyUsageOID.String(): false, + keyUsageOID.String(): false, + subjectKeyIdentifierOID.String(): false, } for _, ext := range c.Extensions { - found := false - for _, oid := range allowedExtensions { - if ext.Id.Equal(oid) { - found = true - } - } - if !found { + seen, allowed := extensions[ext.Id.String()] + if !allowed { return errResult(fmt.Sprintf("unexpected extension %s", ext.Id.String())) } + if seen { + return errResult(fmt.Sprintf("duplicate extension %s", ext.Id.String())) + } + extensions[ext.Id.String()] = true + } + for oid, seen := range extensions { + if !seen { + return errResult(fmt.Sprintf("missing extension %s", oid)) + } } // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1065 diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go index 07019831cd7..d04454b70b0 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go @@ -232,6 +232,63 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { want: lint.Error, wantSubStr: "RSA modulus has a prime factor smaller than 752", }, + { + // 2^2046 has a bit length of 2047, but still encodes in 256 + // octets, so its encoded modulus size is 2048 bits: it passes the + // size check and fails the parity check instead. + name: "rsa_modulus_leading_zero_bit", + pub: &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 2046), E: 65537}, + want: lint.Error, + wantSubStr: "RSA modulus is even", + }, + { + // 2^2039 encodes in 255 octets, so its encoded modulus size is + // 2040 bits. + name: "rsa_modulus_wrong_encoded_size", + pub: &rsa.PublicKey{N: new(big.Int).Lsh(big.NewInt(1), 2039), E: 65537}, + want: lint.Error, + wantSubStr: "RSA encoded modulus size 2040 is not allowed", + }, + { + name: "critical_aia", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(authorityInformationAccessOID)) + }, + want: lint.Error, + wantSubStr: "authorityInformationAccess extension is critical", + }, + { + name: "critical_certificate_policies", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(certificatePoliciesOID)) + }, + want: lint.Error, + wantSubStr: "certificatePolicies extension is critical", + }, + { + name: "critical_crldp", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(crlDistributionPointsOID)) + }, + want: lint.Error, + wantSubStr: "crlDistributionPoints extension is critical", + }, + { + name: "critical_eku", + mod: func(t *testing.T, tmpl *x509.Certificate) { + criticalizeExt(t, tmpl, asn1.ObjectIdentifier(extKeyUsageOID)) + }, + want: lint.Error, + wantSubStr: "extKeyUsage extension is critical", + }, + { + name: "duplicate_extension", + mod: func(t *testing.T, tmpl *x509.Certificate) { + duplicateExt(t, tmpl, asn1.ObjectIdentifier(keyUsageOID)) + }, + want: lint.Error, + wantSubStr: "duplicate extension 2.5.29.15", + }, { name: "aia_contains_ocsp", mod: func(t *testing.T, tmpl *x509.Certificate) { From c87bf39a5d42d9acc59f23abd46335f14e94665f Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 28 Jul 2026 16:44:53 -0700 Subject: [PATCH 14/18] Move GenerateSKID into Core for sharing across ca, ceremony, and lints --- ca/ca.go | 29 +----------- ca/ca_test.go | 12 ----- cmd/ceremony/cert.go | 25 ++--------- cmd/ceremony/cert_test.go | 95 ++++++++++++++++++++------------------- cmd/ceremony/key.go | 8 ++-- cmd/ceremony/main.go | 30 ++++++------- cmd/ceremony/main_test.go | 8 ++-- core/util.go | 25 +++++++++++ core/util_test.go | 23 ++++++++++ issuance/cert_test.go | 62 +++++++++++++++++++------ 10 files changed, 171 insertions(+), 146 deletions(-) diff --git a/ca/ca.go b/ca/ca.go index 5ce5c07d01f..ebad4c709cf 100644 --- a/ca/ca.go +++ b/ca/ca.go @@ -3,12 +3,8 @@ package ca import ( "bytes" "context" - "crypto" "crypto/rand" - "crypto/sha256" "crypto/x509" - "crypto/x509/pkix" - "encoding/asn1" "encoding/hex" "errors" "fmt" @@ -251,7 +247,7 @@ func (ca *certificateAuthorityImpl) IssueCertificate(ctx context.Context, req *c return nil, err } - subjectKeyId, err := generateSKID(csr.PublicKey) + subjectKeyId, err := core.GenerateSKID(csr.PublicKey) if err != nil { return nil, fmt.Errorf("computing subject key ID: %w", err) } @@ -492,29 +488,6 @@ func (ca *certificateAuthorityImpl) generateSerialNumber() *big.Int { return serialBigInt } -// generateSKID computes the Subject Key Identifier using one of the methods in -// RFC 7093 Section 2 Additional Methods for Generating Key Identifiers: -// The keyIdentifier [may be] composed of the leftmost 160-bits of the -// SHA-256 hash of the value of the BIT STRING subjectPublicKey -// (excluding the tag, length, and number of unused bits). -func generateSKID(pk crypto.PublicKey) ([]byte, error) { - pkBytes, err := x509.MarshalPKIXPublicKey(pk) - if err != nil { - return nil, err - } - - var pkixPublicKey struct { - Algo pkix.AlgorithmIdentifier - BitString asn1.BitString - } - if _, err := asn1.Unmarshal(pkBytes, &pkixPublicKey); err != nil { - return nil, err - } - - skid := sha256.Sum256(pkixPublicKey.BitString.Bytes) - return skid[0:20:20], nil -} - // verifyTBSCertIsDeterministic verifies that x509.CreateCertificate signing // operation is deterministic and produced identical DER bytes between the given // lint certificate and leaf certificate. If the DER byte equality check fails diff --git a/ca/ca_test.go b/ca/ca_test.go index 81f4737c6f7..0bafcebabdc 100644 --- a/ca/ca_test.go +++ b/ca/ca_test.go @@ -941,18 +941,6 @@ func TestNoteSignError(t *testing.T) { test.AssertMetricWithLabelsEquals(t, metrics.signErrorCount, prometheus.Labels{"type": "HSM"}, 1) } -func TestGenerateSKID(t *testing.T) { - t.Parallel() - key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - test.AssertNotError(t, err, "Error generating key") - - sha256skid, err := generateSKID(key.Public()) - test.AssertNotError(t, err, "Error generating SKID") - test.AssertEquals(t, len(sha256skid), 20) - test.AssertEquals(t, cap(sha256skid), 20) - features.Reset() -} - func TestVerifyTBSCertIsDeterministic(t *testing.T) { t.Parallel() diff --git a/cmd/ceremony/cert.go b/cmd/ceremony/cert.go index 35ee938f929..e70c3829bd7 100644 --- a/cmd/ceremony/cert.go +++ b/cmd/ceremony/cert.go @@ -2,10 +2,8 @@ package main import ( "crypto" - "crypto/sha256" "crypto/x509" "crypto/x509/pkix" - "encoding/asn1" "errors" "fmt" "io" @@ -13,6 +11,8 @@ import ( "regexp" "slices" "time" + + "github.com/letsencrypt/boulder/core" ) type policyInfoConfig struct { @@ -187,25 +187,8 @@ var stringToKeyUsage = map[string]x509.KeyUsage{ "Cert Sign": x509.KeyUsageCertSign, } -func generateSKID(pk []byte) ([]byte, error) { - var pkixPublicKey struct { - Algo pkix.AlgorithmIdentifier - BitString asn1.BitString - } - if _, err := asn1.Unmarshal(pk, &pkixPublicKey); err != nil { - return nil, err - } - - // RFC 7093 Section 2 Additional Methods for Generating Key Identifiers: The - // keyIdentifier [may be] composed of the leftmost 160-bits of the SHA-256 - // hash of the value of the BIT STRING subjectPublicKey (excluding the tag, - // length, and number of unused bits). - skid := sha256.Sum256(pkixPublicKey.BitString.Bytes) - return skid[0:20:20], nil -} - // makeTemplate generates the certificate template for use in x509.CreateCertificate -func makeTemplate(randReader io.Reader, profile *certProfile, pubKey []byte, tbcs *x509.Certificate, ct certType) (*x509.Certificate, error) { +func makeTemplate(randReader io.Reader, profile *certProfile, pubKey crypto.PublicKey, tbcs *x509.Certificate, ct certType) (*x509.Certificate, error) { // Handle "unrestricted" vs "restricted" subordinate CA profile specifics. if ct == crossCert && tbcs == nil { return nil, fmt.Errorf("toBeCrossSigned cert field was nil, but was required to gather EKUs for the lint cert") @@ -220,7 +203,7 @@ func makeTemplate(randReader io.Reader, profile *certProfile, pubKey []byte, tbc issuingCertificateURL = []string{profile.IssuerURL} } - subjectKeyID, err := generateSKID(pubKey) + subjectKeyID, err := core.GenerateSKID(pubKey) if err != nil { return nil, err } diff --git a/cmd/ceremony/cert_test.go b/cmd/ceremony/cert_test.go index 2fd8f8c11f9..c0cbc96d723 100644 --- a/cmd/ceremony/cert_test.go +++ b/cmd/ceremony/cert_test.go @@ -6,7 +6,6 @@ import ( "crypto/rand" "crypto/x509" "crypto/x509/pkix" - "encoding/hex" "errors" "fmt" "io/fs" @@ -22,16 +21,6 @@ import ( "github.com/letsencrypt/boulder/test" ) -// samplePubkey returns a slice of bytes containing an encoded -// SubjectPublicKeyInfo for an example public key. -func samplePubkey() []byte { - pubKey, err := hex.DecodeString("3059301306072a8648ce3d020106082a8648ce3d03010703420004b06745ef0375c9c54057098f077964e18d3bed0aacd54545b16eab8c539b5768cc1cea93ba56af1e22a7a01c33048c8885ed17c9c55ede70649b707072689f5e") - if err != nil { - panic(err) - } - return pubKey -} - func realRand(_ pkcs11.SessionHandle, length int) ([]byte, error) { r := make([]byte, length) _, err := rand.Read(r) @@ -55,9 +44,13 @@ func TestMakeSubject(t *testing.T) { func TestMakeTemplateEnforcesRootNoEKUs(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() randReader := newRandReader(s) - pubKey := samplePubkey() ctx.GenerateRandomFunc = realRand + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + workingRootProfile := &certProfile{ EKUs: "", KeyUsages: []string{"Digital Signature", "CRL Sign"}, @@ -65,27 +58,27 @@ func TestMakeTemplateEnforcesRootNoEKUs(t *testing.T) { NotBefore: "2026-05-11 00:00:00", NotAfter: "2026-05-12 00:00:00", } - _, err := makeTemplate(randReader, workingRootProfile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, workingRootProfile, key.Public(), nil, rootCert) if err != nil { t.Fatalf("makeTemplate with workingRootProfile: %s", err) } workingRootProfile.EKUs = "none" - _, err = makeTemplate(randReader, workingRootProfile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, workingRootProfile, key.Public(), nil, rootCert) if err != nil { t.Fatalf("makeTemplate with workingRootProfile: %s", err) } brokenRootProfile := *workingRootProfile brokenRootProfile.EKUs = "both" - _, err = makeTemplate(randReader, &brokenRootProfile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, &brokenRootProfile, key.Public(), nil, rootCert) if err == nil { t.Errorf("makeTemplate with brokenRootProfile: got nil error, want error") } brokenRootProfile = *workingRootProfile brokenRootProfile.EKUs = "unintelligible" - _, err = makeTemplate(randReader, &brokenRootProfile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, &brokenRootProfile, key.Public(), nil, rootCert) if err == nil { t.Errorf("makeTemplate with brokenRootProfile: got nil error, want error") } @@ -94,9 +87,13 @@ func TestMakeTemplateEnforcesRootNoEKUs(t *testing.T) { func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() randReader := newRandReader(s) - pubKey := samplePubkey() ctx.GenerateRandomFunc = realRand + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + tbcsCert := &x509.Certificate{ SerialNumber: big.NewInt(666), Subject: pkix.Name{ @@ -117,7 +114,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { NotAfter: "2026-05-12 00:00:00", } - template, err := makeTemplate(randReader, crossCertProfile, pubKey, tbcsCert, crossCert) + template, err := makeTemplate(randReader, crossCertProfile, key.Public(), tbcsCert, crossCert) if err != nil { t.Fatalf("makeTemplate with crossCertProfile: %s", err) } @@ -128,7 +125,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { } crossCertProfile.EKUs = "server" - _, err = makeTemplate(randReader, crossCertProfile, pubKey, tbcsCert, crossCert) + _, err = makeTemplate(randReader, crossCertProfile, key.Public(), tbcsCert, crossCert) if err != nil { t.Fatalf("makeTemplate with crossCertProfile: %s", err) } @@ -139,7 +136,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { // This will error because the tbcsCert has [serverAuth], but "both" means [serverAuth, clientAuth] on the cross sign crossCertProfile.EKUs = "both" - _, err = makeTemplate(randReader, crossCertProfile, pubKey, tbcsCert, crossCert) + _, err = makeTemplate(randReader, crossCertProfile, key.Public(), tbcsCert, crossCert) if err == nil { t.Fatalf("makeTemplate with \"both\" and to-be-cross-signed certificate that has \"serverAuth\": got nil error, want error") } @@ -148,7 +145,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { liberalTBCS := *tbcsCert liberalTBCS.ExtKeyUsage = nil crossCertProfile.EKUs = "both" - _, err = makeTemplate(randReader, crossCertProfile, pubKey, &liberalTBCS, crossCert) + _, err = makeTemplate(randReader, crossCertProfile, key.Public(), &liberalTBCS, crossCert) if err != nil { t.Errorf("makeTemplate with \"both\" and liberal to-be-cross-signed certificate: %s", err) } @@ -157,7 +154,7 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { crossCertProfile.EKUs = "both" crossCertProfile.NotBefore = "2027-05-11 00:00:00" crossCertProfile.NotAfter = "2027-05-12 00:00:00" - _, err = makeTemplate(randReader, crossCertProfile, pubKey, &liberalTBCS, crossCert) + _, err = makeTemplate(randReader, crossCertProfile, key.Public(), &liberalTBCS, crossCert) if err == nil { t.Fatalf("makeTemplate with \"both\" and late notBefore: go nil error, want error") } @@ -169,9 +166,13 @@ func TestMakeTemplateEnforcesCrossCertEKUs(t *testing.T) { func TestMakeTemplateIntermediateEKUs(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() randReader := newRandReader(s) - pubKey := samplePubkey() ctx.GenerateRandomFunc = realRand + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + intermediateProfile := &certProfile{ EKUs: "", KeyUsages: []string{"Digital Signature", "CRL Sign"}, @@ -180,7 +181,7 @@ func TestMakeTemplateIntermediateEKUs(t *testing.T) { NotAfter: "2026-05-12 00:00:00", } - template, err := makeTemplate(randReader, intermediateProfile, pubKey, nil, intermediateCert) + template, err := makeTemplate(randReader, intermediateProfile, key.Public(), nil, intermediateCert) if err != nil { t.Fatalf("makeTemplate with intermediateProfile: %s", err) } @@ -191,7 +192,7 @@ func TestMakeTemplateIntermediateEKUs(t *testing.T) { } intermediateProfile.EKUs = "server" - template, err = makeTemplate(randReader, intermediateProfile, pubKey, nil, intermediateCert) + template, err = makeTemplate(randReader, intermediateProfile, key.Public(), nil, intermediateCert) if err != nil { t.Fatalf("makeTemplate with intermediateProfile and EKUs: \"server\": %s", err) } @@ -202,7 +203,7 @@ func TestMakeTemplateIntermediateEKUs(t *testing.T) { } intermediateProfile.EKUs = "both" - template, err = makeTemplate(randReader, intermediateProfile, pubKey, nil, intermediateCert) + template, err = makeTemplate(randReader, intermediateProfile, key.Public(), nil, intermediateCert) if err != nil { t.Fatalf("makeTemplate with intermediateProfile and EKUs: \"both\": %s", err) } @@ -213,7 +214,7 @@ func TestMakeTemplateIntermediateEKUs(t *testing.T) { } intermediateProfile.EKUs = "unintelligible" - _, err = makeTemplate(randReader, intermediateProfile, pubKey, nil, intermediateCert) + _, err = makeTemplate(randReader, intermediateProfile, key.Public(), nil, intermediateCert) if err == nil { t.Fatalf("makeTemplate with intermediateProfile and EKUs: \"unintelligible\": got nil error, want error") } @@ -223,42 +224,46 @@ func TestMakeTemplateRoot(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() profile := &certProfile{} randReader := newRandReader(s) - pubKey := samplePubkey() ctx.GenerateRandomFunc = realRand + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + profile.NotBefore = "1234" - _, err := makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid not before") profile.NotBefore = "2018-05-18 11:31:00" profile.NotAfter = "1234" - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid not after") profile.NotAfter = "2018-05-18 11:31:00" profile.SignatureAlgorithm = "nope" - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid signature algorithm") profile.SignatureAlgorithm = "SHA256WithRSA" ctx.GenerateRandomFunc = func(pkcs11.SessionHandle, int) ([]byte, error) { return nil, errors.New("bad") } - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail when GenerateRandom failed") ctx.GenerateRandomFunc = realRand - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with empty key usages") profile.KeyUsages = []string{"asd"} - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid key usages") profile.KeyUsages = []string{"Digital Signature", "CRL Sign"} profile.Policies = []policyInfoConfig{{}} - _, err = makeTemplate(randReader, profile, pubKey, nil, rootCert) + _, err = makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertError(t, err, "makeTemplate didn't fail with invalid (empty) policy OID") profile.Policies = []policyInfoConfig{{OID: "1.2.3"}, {OID: "1.2.3.4"}} @@ -267,7 +272,7 @@ func TestMakeTemplateRoot(t *testing.T) { profile.Country = "country" profile.CRLURL = "crl" profile.IssuerURL = "issuer" - cert, err := makeTemplate(randReader, profile, pubKey, nil, rootCert) + cert, err := makeTemplate(randReader, profile, key.Public(), nil, rootCert) test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected") test.AssertEquals(t, cert.Subject.CommonName, profile.CommonName) test.AssertEquals(t, len(cert.Subject.Organization), 1) @@ -282,7 +287,7 @@ func TestMakeTemplateRoot(t *testing.T) { test.AssertEquals(t, len(cert.Policies), 2) test.AssertEquals(t, len(cert.ExtKeyUsage), 0) - cert, err = makeTemplate(randReader, profile, pubKey, nil, intermediateCert) + cert, err = makeTemplate(randReader, profile, key.Public(), nil, intermediateCert) test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected") test.Assert(t, cert.MaxPathLenZero, "MaxPathLenZero not set in intermediate template") test.AssertEquals(t, len(cert.ExtKeyUsage), 1) @@ -293,7 +298,12 @@ func TestMakeTemplateRestrictedCrossCertificate(t *testing.T) { s, ctx := pkcs11helpers.NewSessionWithMock() ctx.GenerateRandomFunc = realRand randReader := newRandReader(s) - pubKey := samplePubkey() + + key, err := ecdsa.GenerateKey(elliptic.P256(), nil) + if err != nil { + t.Fatalf("generating test keypair: %s", err) + } + profile := &certProfile{ SignatureAlgorithm: "SHA256WithRSA", CommonName: "common name", @@ -318,7 +328,7 @@ func TestMakeTemplateRestrictedCrossCertificate(t *testing.T) { BasicConstraintsValid: true, } - cert, err := makeTemplate(randReader, profile, pubKey, &tbcsCert, crossCert) + cert, err := makeTemplate(randReader, profile, key.Public(), &tbcsCert, crossCert) test.AssertNotError(t, err, "makeTemplate failed when everything worked as expected") test.Assert(t, !cert.MaxPathLenZero, "MaxPathLenZero was set in cross-sign") test.AssertEquals(t, len(cert.ExtKeyUsage), 1) @@ -575,10 +585,3 @@ func TestLoadCert(t *testing.T) { _, err = loadCert("../../test/hierarchy/int-e1.key.pem") test.AssertError(t, err, "should have failed when trying to parse a private key") } - -func TestGenerateSKID(t *testing.T) { - sha256skid, err := generateSKID(samplePubkey()) - test.AssertNotError(t, err, "Error generating SKID") - test.AssertEquals(t, len(sha256skid), 20) - test.AssertEquals(t, cap(sha256skid), 20) -} diff --git a/cmd/ceremony/key.go b/cmd/ceremony/key.go index 2315a2081a3..5db5987d8a2 100644 --- a/cmd/ceremony/key.go +++ b/cmd/ceremony/key.go @@ -36,12 +36,10 @@ type generateArgs struct { } // keyInfo is a struct used to pass around information about the public key -// associated with the generated private key. der contains the DER encoding -// of the SubjectPublicKeyInfo structure for the public key. id contains the -// HSM key pair object ID. +// associated with the generated private key. id contains the HSM key pair +// object ID. type keyInfo struct { key crypto.PublicKey - der []byte id []byte } @@ -81,5 +79,5 @@ func generateKey(session *pkcs11helpers.Session, label string, outputPath string } log.Printf("Public key written to %q\n", outputPath) - return &keyInfo{key: pubKey, der: der, id: keyID}, nil + return &keyInfo{key: pubKey, id: keyID}, nil } diff --git a/cmd/ceremony/main.go b/cmd/ceremony/main.go index 604a06a3536..d7f87b80515 100644 --- a/cmd/ceremony/main.go +++ b/cmd/ceremony/main.go @@ -534,30 +534,28 @@ func signAndWriteCert(tbs, issuer *x509.Certificate, lintCert lintCert, subjectP return cert, nil } -// loadPubKey loads a PEM public key specified by filename. It returns a -// crypto.PublicKey, the PEM bytes of the public key, and an error. If an error -// exists, no public key or bytes are returned. The public key is checked by the -// GoodKey package. -func loadPubKey(filename string) (crypto.PublicKey, []byte, error) { +// loadPubKey loads a PEM public key specified by filename. The public key is +// checked by the GoodKey package. +func loadPubKey(filename string) (crypto.PublicKey, error) { keyPEM, err := os.ReadFile(filename) if err != nil { - return nil, nil, err + return nil, err } log.Printf("Loaded public key from %s\n", filename) block, _ := pem.Decode(keyPEM) if block == nil { - return nil, nil, fmt.Errorf("no data in cert PEM file %q", filename) + return nil, fmt.Errorf("no data in cert PEM file %q", filename) } key, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { - return nil, nil, err + return nil, err } err = kp.GoodKey(context.Background(), key) if err != nil { - return nil, nil, err + return nil, err } - return key, block.Bytes, nil + return key, nil } func rootCeremony(configBytes []byte) error { @@ -584,7 +582,7 @@ func rootCeremony(configBytes []byte) error { if err != nil { return fmt.Errorf("failed to retrieve signer: %s", err) } - template, err := makeTemplate(newRandReader(session), &config.CertProfile, keyInfo.der, nil, rootCert) + template, err := makeTemplate(newRandReader(session), &config.CertProfile, keyInfo.key, nil, rootCert) if err != nil { return fmt.Errorf("failed to create certificate profile: %s", err) } @@ -616,7 +614,7 @@ func intermediateCeremony(configBytes []byte) error { if err != nil { return fmt.Errorf("failed to validate config: %s", err) } - pub, pubBytes, err := loadPubKey(config.Inputs.PublicKeyPath) + pub, err := loadPubKey(config.Inputs.PublicKeyPath) if err != nil { return err } @@ -628,7 +626,7 @@ func intermediateCeremony(configBytes []byte) error { if err != nil { return err } - template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, nil, intermediateCert) + template, err := makeTemplate(randReader, &config.CertProfile, pub, nil, intermediateCert) if err != nil { return fmt.Errorf("failed to create certificate profile: %s", err) } @@ -668,7 +666,7 @@ func crossCertCeremony(configBytes []byte) error { if err != nil { return fmt.Errorf("failed to validate config: %s", err) } - pub, pubBytes, err := loadPubKey(config.Inputs.PublicKeyPath) + pub, err := loadPubKey(config.Inputs.PublicKeyPath) if err != nil { return err } @@ -684,7 +682,7 @@ func crossCertCeremony(configBytes []byte) error { if err != nil { return err } - template, err := makeTemplate(randReader, &config.CertProfile, pubBytes, toBeCrossSigned, crossCert) + template, err := makeTemplate(randReader, &config.CertProfile, pub, toBeCrossSigned, crossCert) if err != nil { return fmt.Errorf("failed to create certificate profile: %s", err) } @@ -770,7 +768,7 @@ func csrCeremony(configBytes []byte) error { return fmt.Errorf("failed to validate config: %s", err) } - pub, _, err := loadPubKey(config.Inputs.PublicKeyPath) + pub, err := loadPubKey(config.Inputs.PublicKeyPath) if err != nil { return err } diff --git a/cmd/ceremony/main_test.go b/cmd/ceremony/main_test.go index 899cb2909cc..697d97ec172 100644 --- a/cmd/ceremony/main_test.go +++ b/cmd/ceremony/main_test.go @@ -24,21 +24,21 @@ func TestLoadPubKey(t *testing.T) { tmp := t.TempDir() key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - _, _, err := loadPubKey(path.Join(tmp, "does", "not", "exist")) + _, err := loadPubKey(path.Join(tmp, "does", "not", "exist")) test.AssertError(t, err, "should fail on non-existent file") test.AssertErrorIs(t, err, fs.ErrNotExist) - _, _, err = loadPubKey("../../test/hierarchy/README.md") + _, err = loadPubKey("../../test/hierarchy/README.md") test.AssertError(t, err, "should fail on non-PEM file") priv, _ := x509.MarshalPKCS8PrivateKey(key) _ = os.WriteFile(path.Join(tmp, "priv.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: priv}), 0644) - _, _, err = loadPubKey(path.Join(tmp, "priv.pem")) + _, err = loadPubKey(path.Join(tmp, "priv.pem")) test.AssertError(t, err, "should fail on non-pubkey PEM") pub, _ := x509.MarshalPKIXPublicKey(key.Public()) _ = os.WriteFile(path.Join(tmp, "pub.pem"), pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pub}), 0644) - _, _, err = loadPubKey(path.Join(tmp, "pub.pem")) + _, err = loadPubKey(path.Join(tmp, "pub.pem")) test.AssertNotError(t, err, "should not have errored") } diff --git a/core/util.go b/core/util.go index 97892b122d0..6e9282de9d5 100644 --- a/core/util.go +++ b/core/util.go @@ -8,6 +8,8 @@ import ( "crypto/rsa" "crypto/sha256" "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" "encoding/base64" "encoding/hex" "encoding/pem" @@ -166,6 +168,29 @@ func PublicKeysEqual(a, b crypto.PublicKey) (bool, error) { } } +// GenerateSKID computes the Subject Key Identifier using one of the methods in +// RFC 7093 Section 2 Additional Methods for Generating Key Identifiers: +// The keyIdentifier [may be] composed of the leftmost 160-bits of the +// SHA-256 hash of the value of the BIT STRING subjectPublicKey +// (excluding the tag, length, and number of unused bits). +func GenerateSKID(pub crypto.PublicKey) ([]byte, error) { + pkBytes, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + return nil, err + } + + var pkixPublicKey struct { + Algo pkix.AlgorithmIdentifier + BitString asn1.BitString + } + if _, err := asn1.Unmarshal(pkBytes, &pkixPublicKey); err != nil { + return nil, err + } + + skid := sha256.Sum256(pkixPublicKey.BitString.Bytes) + return skid[0:20:20], nil +} + // SerialToString converts a certificate serial number (big.Int) to a String // consistently. func SerialToString(serial *big.Int) string { diff --git a/core/util_test.go b/core/util_test.go index a36b7307ab5..db4ad12b7e6 100644 --- a/core/util_test.go +++ b/core/util_test.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "crypto/x509" "crypto/x509/pkix" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -122,6 +123,28 @@ func TestKeyDigestEquals(t *testing.T) { test.Assert(t, !KeyDigestEquals(struct{}{}, struct{}{}), "Unknown key types should not match anything") } +func TestGenerateSKID(t *testing.T) { + t.Parallel() + + // Test basics with a random key. + key1, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + test.AssertNotError(t, err, "Error generating key") + skid, err := GenerateSKID(key1.Public()) + test.AssertNotError(t, err, "Error generating SKID") + test.AssertEquals(t, len(skid), 20) + test.AssertEquals(t, cap(skid), 20) + + // Test specific output with the known test vector from RFC 7093 Section 3: + spkiDER, err := hex.DecodeString( + "3059301306072a8648ce3d020106082a8648ce3d030107034200047f7f35a79794c950060b8029fc8f363a28f11159692d9d34e6ac948190434735f833b1a66652dc514337aff7f5c9c75d670c019d95a5d639b72744c64a9128bb") + test.AssertNotError(t, err, "Error decoding test vector SPKI") + key2, err := x509.ParsePKIXPublicKey(spkiDER) + test.AssertNotError(t, err, "Error parsing test vector SPKI") + skid, err = GenerateSKID(key2) + test.AssertNotError(t, err, "Error generating SKID") + test.AssertEquals(t, hex.EncodeToString(skid), "bf37b3e5808fd46d54b28e846311bcce1cad2e1a") +} + // TestCertKeyDigest ensures that CertKeyDigest (which hashes a certificate's // SubjectPublicKeyInfo) and KeyDigest (which hashes an in-memory public key, // usually from a parsed JWK) produce the same result for the same underlying diff --git a/issuance/cert_test.go b/issuance/cert_test.go index aa184d00621..0e245f30227 100644 --- a/issuance/cert_test.go +++ b/issuance/cert_test.go @@ -21,12 +21,16 @@ import ( "github.com/jmhodges/clock" "github.com/letsencrypt/boulder/config" + "github.com/letsencrypt/boulder/core" "github.com/letsencrypt/boulder/ctpolicy/loglist" "github.com/letsencrypt/boulder/linter" "github.com/letsencrypt/boulder/test" ) var ( + // goodSKID is a fake subject key ID for tests which only exercise request + // validation. Tests which also run lints must use core.GenerateSKID because + // our custom lints verify that the SKID matches the actual public key. goodSKID = []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9} ) @@ -357,9 +361,11 @@ func TestIssue(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := tc.generateFunc() test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") lintCertBytes, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, IPAddresses: []net.IP{net.ParseIP("128.101.101.101"), net.ParseIP("3fff:aaa:a:c0ff:ee:a:bad:deed")}, @@ -440,9 +446,13 @@ func TestIssueDNSNamesOnly(t *testing.T) { if err != nil { t.Fatalf("ecdsa.GenerateKey: %s", err) } + skid, err := core.GenerateSKID(pk.Public()) + if err != nil { + t.Fatalf("core.GenerateSKID: %s", err) + } _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), @@ -479,9 +489,13 @@ func TestIssueIPAddressesOnly(t *testing.T) { if err != nil { t.Fatalf("ecdsa.GenerateKey: %s", err) } + skid, err := core.GenerateSKID(pk.Public()) + if err != nil { + t.Fatalf("core.GenerateSKID: %s", err) + } _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, IPAddresses: []net.IP{net.ParseIP("128.101.101.101"), net.ParseIP("3fff:aaa:a:c0ff:ee:a:bad:deed")}, NotBefore: fc.Now(), @@ -521,10 +535,14 @@ func TestIssueWithCRLDP(t *testing.T) { if err != nil { t.Fatalf("ecdsa.GenerateKey: %s", err) } + skid, err := core.GenerateSKID(pk.Public()) + if err != nil { + t.Fatalf("core.GenerateSKID: %s", err) + } profile := defaultProfile() _, issuanceToken, err := signer.Prepare(profile, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), @@ -561,9 +579,11 @@ func TestIssueCommonName(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") ir := &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com", "www.example.com"}, NotBefore: fc.Now(), @@ -697,9 +717,11 @@ func TestIssueOmissions(t *testing.T) { pk, err := rsa.GenerateKey(rand.Reader, 2048) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := signer.Prepare(prof, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, CommonName: "example.com", @@ -726,9 +748,11 @@ func TestIssueCTPoison(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, IncludeCTPoison: true, @@ -774,9 +798,11 @@ func TestIssueSCTList(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := signer.Prepare(enforceSCTsProfile, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), @@ -842,9 +868,11 @@ func TestIssueBadLint(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, _, err = signer.Prepare(noSkipLintsProfile, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example-com"}, NotBefore: fc.Now(), @@ -871,9 +899,11 @@ func TestIssuanceToken(t *testing.T) { pk, err := rsa.GenerateKey(rand.Reader, 2048) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), @@ -890,7 +920,7 @@ func TestIssuanceToken(t *testing.T) { _, issuanceToken, err = signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), @@ -918,9 +948,11 @@ func TestInvalidProfile(t *testing.T) { test.AssertNotError(t, err, "NewIssuer failed") pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, _, err = signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), @@ -932,7 +964,7 @@ func TestInvalidProfile(t *testing.T) { _, _, err = signer.Prepare(defaultProfile(), &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, DNSNames: []string{"example.com"}, NotBefore: fc.Now(), @@ -967,9 +999,11 @@ func TestMismatchedProfiles(t *testing.T) { pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "failed to generate test key") + skid, err := core.GenerateSKID(pk.Public()) + test.AssertNotError(t, err, "failed to compute subject key ID") _, issuanceToken, err := issuer1.Prepare(cnProfile, &IssuanceRequest{ PublicKey: MarshalablePublicKey{pk.Public()}, - SubjectKeyId: goodSKID, + SubjectKeyId: skid, Serial: []byte{1, 2, 3, 4, 5, 6, 7, 8, 9}, CommonName: "example.com", DNSNames: []string{"example.com"}, From 06a2433655a79243ace60559202acb8bbff1ea92 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 28 Jul 2026 16:59:30 -0700 Subject: [PATCH 15/18] Create scaffolding for configuring lints with issuers --- cmd/ceremony/main.go | 39 ++++++-- cmd/ceremony/main_test.go | 4 +- issuance/cert.go | 22 ++-- linter/config.go | 121 ++++++++++++++++++++++ linter/config_test.go | 183 ++++++++++++++++++++++++++++++++++ linter/linter.go | 22 +++- linter/linter_test.go | 2 +- linter/lints/cpcps/helpers.go | 65 ++++++++++++ 8 files changed, 435 insertions(+), 23 deletions(-) create mode 100644 linter/config.go create mode 100644 linter/config_test.go create mode 100644 linter/lints/cpcps/helpers.go diff --git a/cmd/ceremony/main.go b/cmd/ceremony/main.go index d7f87b80515..0ee69aa9fb7 100644 --- a/cmd/ceremony/main.go +++ b/cmd/ceremony/main.go @@ -46,8 +46,15 @@ type lintCert *x509.Certificate // template certificate signed by a given issuer and returns a *lintCert or an // error. The lint certificate is linted prior to being returned. The public key // from the just issued lint certificate is checked by the GoodKey package. -func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey crypto.PublicKey, signer crypto.Signer, skipLints []string) (lintCert, error) { - bytes, err := linter.Check(tbs, subjectPubKey, issuer, signer, skipLints) +// When cross-signing, existing is the pre-existing certificate of the CA being +// cross-signed, allowing the CP/CPS profile lints to check correspondence with +// it; it is nil otherwise. +func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey crypto.PublicKey, signer crypto.Signer, existing *x509.Certificate, skipLints []string) (lintCert, error) { + lintConfig, err := linter.Config{}.WithExisting(existing) + if err != nil { + return nil, fmt.Errorf("unable to create lint config: %w", err) + } + bytes, err := linter.Check(tbs, subjectPubKey, issuer, signer, lintConfig, skipLints) if err != nil { return nil, fmt.Errorf("certificate failed pre-issuance lint: %w", err) } @@ -66,8 +73,10 @@ func issueLintCertAndPerformLinting(tbs, issuer *x509.Certificate, subjectPubKey // postIssuanceLinting performs post-issuance linting on the raw bytes of a // given certificate with the same set of lints as // issueLintCertAndPerformLinting. The public key is also checked by the GoodKey -// package. -func postIssuanceLinting(fc *x509.Certificate, skipLints []string) error { +// package. The issuer and existing certificates, when non-nil, are supplied to +// the CP/CPS profile lints so that they can perform their correspondence +// checks. +func postIssuanceLinting(fc, issuer, existing *x509.Certificate, skipLints []string) error { if fc == nil { return fmt.Errorf("certificate was not provided") } @@ -77,7 +86,15 @@ func postIssuanceLinting(fc *x509.Certificate, skipLints []string) error { // lint. This should be treated as ZLint rejecting the certificate return fmt.Errorf("unable to parse certificate: %s", err) } - registry, err := linter.NewRegistry(skipLints) + lintConfig, err := linter.Config{}.WithIssuer(issuer) + if err != nil { + return fmt.Errorf("unable to create lint config: %s", err) + } + lintConfig, err = lintConfig.WithExisting(existing) + if err != nil { + return fmt.Errorf("unable to create lint config: %s", err) + } + registry, err := linter.NewRegistryWithConfig(skipLints, lintConfig) if err != nil { return fmt.Errorf("unable to create zlint registry: %s", err) } @@ -586,7 +603,7 @@ func rootCeremony(configBytes []byte) error { if err != nil { return fmt.Errorf("failed to create certificate profile: %s", err) } - lintCert, err := issueLintCertAndPerformLinting(template, template, keyInfo.key, signer, config.SkipLints) + lintCert, err := issueLintCertAndPerformLinting(template, template, keyInfo.key, signer, nil, config.SkipLints) if err != nil { return err } @@ -594,7 +611,7 @@ func rootCeremony(configBytes []byte) error { if err != nil { return err } - err = postIssuanceLinting(finalCert, config.SkipLints) + err = postIssuanceLinting(finalCert, nil, nil, config.SkipLints) if err != nil { return err } @@ -631,7 +648,7 @@ func intermediateCeremony(configBytes []byte) error { return fmt.Errorf("failed to create certificate profile: %s", err) } template.AuthorityKeyId = issuer.SubjectKeyId - lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, config.SkipLints) + lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, nil, config.SkipLints) if err != nil { return err } @@ -646,7 +663,7 @@ func intermediateCeremony(configBytes []byte) error { if !bytes.Equal(lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) { return fmt.Errorf("mismatch between lintCert and finalCert RawTBSCertificate DER bytes: \"%x\" != \"%x\"", lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) } - err = postIssuanceLinting(finalCert, config.SkipLints) + err = postIssuanceLinting(finalCert, issuer, nil, config.SkipLints) if err != nil { return err } @@ -687,7 +704,7 @@ func crossCertCeremony(configBytes []byte) error { return fmt.Errorf("failed to create certificate profile: %s", err) } template.AuthorityKeyId = issuer.SubjectKeyId - lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, config.SkipLints) + lintCert, err := issueLintCertAndPerformLinting(template, issuer, pub, signer, toBeCrossSigned, config.SkipLints) if err != nil { return err } @@ -748,7 +765,7 @@ func crossCertCeremony(configBytes []byte) error { if !bytes.Equal(lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) { return fmt.Errorf("mismatch between lintCert and finalCert RawTBSCertificate DER bytes: \"%x\" != \"%x\"", lintCert.RawTBSCertificate, finalCert.RawTBSCertificate) } - err = postIssuanceLinting(finalCert, config.SkipLints) + err = postIssuanceLinting(finalCert, issuer, toBeCrossSigned, config.SkipLints) if err != nil { return err } diff --git a/cmd/ceremony/main_test.go b/cmd/ceremony/main_test.go index 697d97ec172..c6cd04364fa 100644 --- a/cmd/ceremony/main_test.go +++ b/cmd/ceremony/main_test.go @@ -1292,7 +1292,7 @@ func TestSignAndWriteNoLintCert(t *testing.T) { func TestPostIssuanceLinting(t *testing.T) { clk := clock.New() - err := postIssuanceLinting(nil, nil) + err := postIssuanceLinting(nil, nil, nil, nil) test.AssertError(t, err, "should have failed because no certificate was provided") testKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -1306,6 +1306,6 @@ func TestPostIssuanceLinting(t *testing.T) { test.AssertNotError(t, err, "unable to create certificate") parsedCert, err := x509.ParseCertificate(certDer) test.AssertNotError(t, err, "unable to parse DER bytes") - err = postIssuanceLinting(parsedCert, nil) + err = postIssuanceLinting(parsedCert, nil, nil, nil) test.AssertNotError(t, err, "should not have errored") } diff --git a/issuance/cert.go b/issuance/cert.go index f7e23387689..56b42c150f8 100644 --- a/issuance/cert.go +++ b/issuance/cert.go @@ -77,7 +77,14 @@ type Profile struct { maxCertificateSize int + // lints is the registry of lints to run against certificates issued under + // this profile. It carries no lint configuration of its own: at issuance + // time it is combined with a configuration derived from lintConfig. lints lint.Registry + // lintConfig is the in-memory contents of this profile's zlint config + // file. At issuance time it is augmented with the issuing Issuer's + // certificate via WithIssuer. + lintConfig linter.Config } // NewProfile converts the profile config into a usable profile. @@ -102,11 +109,9 @@ func NewProfile(profileConfig ProfileConfig) (*Profile, error) { lints, err := linter.NewRegistry(profileConfig.IgnoredLints) cmd.FailOnError(err, "Failed to create zlint registry") - if profileConfig.LintConfig != "" { - lintconfig, err := lint.NewConfigFromFile(profileConfig.LintConfig) - cmd.FailOnError(err, "Failed to load zlint config file") - lints.SetConfiguration(lintconfig) - } + + lintConfig, err := linter.LoadConfigFile(profileConfig.LintConfig) + cmd.FailOnError(err, "Failed to load zlint config file") sp := &Profile{ omitCommonName: profileConfig.OmitCommonName, @@ -118,6 +123,7 @@ func NewProfile(profileConfig ProfileConfig) (*Profile, error) { maxValidity: profileConfig.MaxValidityPeriod.Duration, maxCertificateSize: profileConfig.MaxCertificateSize, lints: lints, + lintConfig: lintConfig, } return sp, nil @@ -386,7 +392,11 @@ func (i *Issuer) Prepare(prof *Profile, req *IssuanceRequest) ([]byte, *issuance // check that the tbsCertificate is properly formed by signing it // with a throwaway key and then linting it using zlint - lintCertBytes, err := i.Linter.Check(template, req.PublicKey.PublicKey, prof.lints) + lintConfig, err := prof.lintConfig.WithIssuer(i.Cert.Certificate) + if err != nil { + return nil, nil, fmt.Errorf("building lint config: %w", err) + } + lintCertBytes, err := i.Linter.Check(template, req.PublicKey.PublicKey, prof.lints, lintConfig) if err != nil { return nil, nil, fmt.Errorf("tbsCertificate linting failed: %w", err) } diff --git a/linter/config.go b/linter/config.go new file mode 100644 index 00000000000..4e49e99c7a4 --- /dev/null +++ b/linter/config.go @@ -0,0 +1,121 @@ +package linter + +import ( + "crypto/x509" + "encoding/pem" + "fmt" + "os" + + "github.com/pelletier/go-toml" + "github.com/zmap/zlint/v3/lint" + + "github.com/letsencrypt/boulder/linter/lints/cpcps" +) + +// Config is a validated, in-memory zlint lint configuration. The zero value +// is an empty configuration. +type Config struct { + // The zlint package only accepts configuration as TOML (and re-parses it on + // every lint pass anyway), so we store the config in zlint's format. + toml string +} + +// LoadConfigFile reads and validates a zlint TOML config file. An empty path +// yields an empty Config. It is an error for the file to set the shared +// configuration keys read by the CP/CPS profile lints: those are derived from +// certificates via WithIssuer and WithExisting instead. +func LoadConfigFile(path string) (Config, error) { + if path == "" { + return Config{}, nil + } + contents, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("failed to read zlint config file: %w", err) + } + tree, err := toml.LoadBytes(contents) + if err != nil { + return Config{}, fmt.Errorf("failed to parse zlint config file %q: %w", path, err) + } + for _, key := range []string{cpcps.IssuerCertificateConfigKey, cpcps.ExistingCertificateConfigKey} { + if tree.HasPath([]string{cpcps.GlobalConfigNamespace, key}) { + return Config{}, fmt.Errorf("zlint config file %q must not set %s.%s: it is derived from the issuer certificate", path, cpcps.GlobalConfigNamespace, key) + } + } + return Config{toml: string(contents)}, nil +} + +// WithIssuer returns a copy of the Config with a stanza holding the PEM of the +// issuer's certificate. This is necessary for the CP/CPS profile lints, which +// check that certain fields of the certificate being linted match the issuer. A +// nil issuer, or one with no raw DER bytes (i.e. a to-be-signed template rather +// than a real certificate, as in a self-signed root ceremony), returns the +// Config unchanged. +func (c Config) WithIssuer(issuer *x509.Certificate) (Config, error) { + if issuer == nil || len(issuer.Raw) == 0 { + return c, nil + } + issuerPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuer.Raw})) + return c.set(cpcps.IssuerCertificateConfigKey, issuerPEM) +} + +// WithExisting returns a copy of the Config with a stanza holding the PEM of +// the pre-existing certificate which is being cross-signed. This is necessary +// for the CP/CPS Cross-Certified Subordinate CA Certificate lint. It is only +// used by the ceremony tool. A nil existing certificate, or one with no raw DER +// bytes, returns the Config unchanged. +func (c Config) WithExisting(existing *x509.Certificate) (Config, error) { + if existing == nil || len(existing.Raw) == 0 { + return c, nil + } + existingPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: existing.Raw})) + return c.set(cpcps.ExistingCertificateConfigKey, existingPEM) +} + +// set returns a copy of the Config with the given key (inside the Global +// namespace) set to the given value. +func (c Config) set(key string, value string) (Config, error) { + tree, err := toml.Load(c.toml) + if err != nil { + return Config{}, fmt.Errorf("failed to parse zlint config: %w", err) + } + tree.SetPath([]string{cpcps.GlobalConfigNamespace, key}, value) + tomlString, err := tree.ToTomlString() + if err != nil { + return Config{}, fmt.Errorf("failed to serialize zlint config: %w", err) + } + return Config{toml: tomlString}, nil +} + +// build converts the Config into the lint.Configuration that zlint consumes. +func (c Config) build() (lint.Configuration, error) { + return lint.NewConfigFromString(c.toml) +} + +// configuredRegistry implements the zlint.Registry interface by embedding a +// normal Registry but replacing the GetConfiguration method with one that +// returns our own config. This allows us to easily supply different config +// objects for each lint pass without having to modify the underlying registry. +type configuredRegistry struct { + lint.Registry + config lint.Configuration +} + +// GetConfiguration returns the config associated with this registry. It +// satisfies the zlint.Registry interface. +func (r configuredRegistry) GetConfiguration() lint.Configuration { + return r.config +} + +// NewRegistryWithConfig is like NewRegistry, but the returned registry also +// carries the contents of the given Config. +func NewRegistryWithConfig(skipLints []string, config Config) (lint.Registry, error) { + reg, err := NewRegistry(skipLints) + if err != nil { + return nil, err + } + lintConfig, err := config.build() + if err != nil { + return nil, err + } + return configuredRegistry{reg, lintConfig}, nil +} diff --git a/linter/config_test.go b/linter/config_test.go new file mode 100644 index 00000000000..ca235ff9931 --- /dev/null +++ b/linter/config_test.go @@ -0,0 +1,183 @@ +package linter + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path" + "strings" + "testing" + "time" + + "github.com/letsencrypt/boulder/linter/lints/cpcps" +) + +// testCert generates a minimal self-signed certificate with the given CN. +func testCert(t *testing.T, cn string) *x509.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generating test key: %s", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + t.Fatalf("creating test certificate: %s", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parsing test certificate: %s", err) + } + return cert +} + +func TestLoadConfigFile(t *testing.T) { + t.Parallel() + + cfg, err := LoadConfigFile("") + if err != nil { + t.Errorf("got error %q for empty path, want nil", err) + } + if cfg.toml != "" { + t.Errorf("got non-empty config %q for empty path", cfg.toml) + } + + dir := t.TempDir() + + good := path.Join(dir, "good.toml") + err = os.WriteFile(good, []byte("[w_subject_common_name_included]\nsomething = true\n"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + _, err = LoadConfigFile(good) + if err != nil { + t.Errorf("got error %q for valid config file, want nil", err) + } + + garbage := path.Join(dir, "garbage.toml") + err = os.WriteFile(garbage, []byte("this is not toml"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + _, err = LoadConfigFile(garbage) + if err == nil { + t.Error("got nil error for invalid config file, want error") + } + + // Config files may not set the shared keys read by the CP/CPS profile + // lints: their configuration is derived from the issuer certificate. + // Other keys in the shared stanza are permitted. + reserved := path.Join(dir, "reserved.toml") + err = os.WriteFile(reserved, []byte("[Global]\nissuer_certificate = \"bogus\"\n"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + _, err = LoadConfigFile(reserved) + if err == nil || !strings.Contains(err.Error(), "must not set") { + t.Errorf("got %v for config file with reserved key, want rejection", err) + } + + otherGlobal := path.Join(dir, "other-global.toml") + err = os.WriteFile(otherGlobal, []byte("[Global]\nsomething_else = true\n"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + _, err = LoadConfigFile(otherGlobal) + if err != nil { + t.Errorf("got error %q for config file with unrelated shared-stanza key, want nil", err) + } + + _, err = LoadConfigFile(path.Join(dir, "does-not-exist.toml")) + if err == nil { + t.Error("got nil error for nonexistent config file, want error") + } +} + +// TestConfigDerivation exercises the whole layering path: a config file +// loaded from disk, augmented with an issuer and an existing certificate, +// built into a single zlint Configuration in which all three are visible. +func TestConfigDerivation(t *testing.T) { + t.Parallel() + + filePath := path.Join(t.TempDir(), "zlint.toml") + err := os.WriteFile(filePath, []byte("[w_subject_common_name_included]\nsomething = true\n"), 0644) + if err != nil { + t.Fatalf("writing config file: %s", err) + } + config, err := LoadConfigFile(filePath) + if err != nil { + t.Fatalf("loading config file: %s", err) + } + + issuer := testCert(t, "issuer") + config, err = config.WithIssuer(issuer) + if err != nil { + t.Fatalf("adding issuer to config: %s", err) + } + + existing := testCert(t, "existing") + config, err = config.WithExisting(existing) + if err != nil { + t.Fatalf("adding existing certificate to config: %s", err) + } + + merged, err := config.build() + if err != nil { + t.Fatalf("building lint configuration: %s", err) + } + + var gotCross struct { + IssuerCertificatePEM string `toml:"issuer_certificate"` + ExistingCertificatePEM string `toml:"existing_certificate"` + } + err = merged.Configure(&gotCross, cpcps.GlobalConfigNamespace) + if err != nil { + t.Fatalf("deserializing issuer lint configuration: %s", err) + } + + issuerBlock, _ := pem.Decode([]byte(gotCross.IssuerCertificatePEM)) + if issuerBlock == nil { + t.Fatal("issuer_certificate did not round-trip as PEM") + } + if !issuer.Equal(mustParse(t, issuerBlock.Bytes)) { + t.Error("issuer_certificate does not match the configured issuer") + } + + existingBlock, _ := pem.Decode([]byte(gotCross.ExistingCertificatePEM)) + if existingBlock == nil { + t.Fatal("existing_certificate did not round-trip as PEM") + } + if !existing.Equal(mustParse(t, existingBlock.Bytes)) { + t.Error("existing_certificate does not match the configured existing certificate") + } + + var gotFile struct { + Something bool `toml:"something"` + } + err = merged.Configure(&gotFile, "w_subject_common_name_included") + if err != nil { + t.Fatalf("deserializing file lint configuration: %s", err) + } + if !gotFile.Something { + t.Error("config-file section is not visible in the merged configuration") + } +} + +func mustParse(t *testing.T, der []byte) *x509.Certificate { + t.Helper() + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parsing certificate: %s", err) + } + return cert +} diff --git a/linter/linter.go b/linter/linter.go index e52aa6a3e81..48f9258715b 100644 --- a/linter/linter.go +++ b/linter/linter.go @@ -30,18 +30,23 @@ var ErrLinting = fmt.Errorf("failed lint(s)") // primary public interface of this package, but it can be inefficient; creating // a new signer and a new lint registry are expensive operations which // performance-sensitive clients may want to cache via linter.New(). -func Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, realIssuer *x509.Certificate, realSigner crypto.Signer, skipLints []string) ([]byte, error) { +func Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, realIssuer *x509.Certificate, realSigner crypto.Signer, config Config, skipLints []string) ([]byte, error) { linter, err := New(realIssuer, realSigner) if err != nil { return nil, err } + config, err = config.WithIssuer(realIssuer) + if err != nil { + return nil, err + } + reg, err := NewRegistry(skipLints) if err != nil { return nil, err } - lintCertBytes, err := linter.Check(tbs, subjectPubKey, reg) + lintCertBytes, err := linter.Check(tbs, subjectPubKey, reg, config) if err != nil { return nil, err } @@ -98,7 +103,18 @@ func New(realIssuer *x509.Certificate, realSigner crypto.Signer) (*Linter, error // replaced with the linter's pubkey so that it appears self-signed. It returns // an error if any lint fails. On success it also returns the DER bytes of the // linting certificate. -func (l *Linter) Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, reg lint.Registry) ([]byte, error) { +func (l *Linter) Check(tbs *x509.Certificate, subjectPubKey crypto.PublicKey, reg lint.Registry, config Config) ([]byte, error) { + if reg == nil { + reg = lint.GlobalRegistry() + } + + lintConfig, err := config.build() + if err != nil { + return nil, err + } + + reg = configuredRegistry{reg, lintConfig} + lintPubKey := subjectPubKey selfSigned, err := core.PublicKeysEqual(subjectPubKey, l.realPubKey) if err != nil { diff --git a/linter/linter_test.go b/linter/linter_test.go index 3b35578e907..b6cfebd917d 100644 --- a/linter/linter_test.go +++ b/linter/linter_test.go @@ -97,7 +97,7 @@ func TestMakeIssuer(t *testing.T) { ee := &x509.Certificate{} - lintCertBytes, err := linter.Check(ee, eeKey.Public(), nil) + lintCertBytes, err := linter.Check(ee, eeKey.Public(), nil, Config{}) if err != nil { t.Fatal(err) } diff --git a/linter/lints/cpcps/helpers.go b/linter/lints/cpcps/helpers.go new file mode 100644 index 00000000000..f637eef7071 --- /dev/null +++ b/linter/lints/cpcps/helpers.go @@ -0,0 +1,65 @@ +package cpcps + +import ( + "github.com/zmap/zlint/v3/lint" +) + +// Keys within the shared configuration stanza. These must match the toml +// tags on SharedConfig's fields, and are exported so that the linter +// package can emit configuration using these keys. +const ( + // GlobalConfigNamespace is the name of the TOML stanza from which + // IssuingCAConfig is deserialized. It must match the namespace of zlint's + // lint.Global higher-scoped configuration, which IssuingCAConfig embeds. + GlobalConfigNamespace = "Global" + // IssuerCertificateConfigKey configures the Issuing CA's certificate. + IssuerCertificateConfigKey = "issuer_certificate" + // ExistingCertificateConfigKey configures the pre-existing certificate of + // a CA being cross-signed. + ExistingCertificateConfigKey = "existing_certificate" +) + +// globalNamespace aliases zlint's lint.Global so that IssuingCAConfig can +// embed it under an unexported field name. The promoted (unexported) +// namespace method is what routes deserialization of IssuingCAConfig to the +// shared [Global] stanza, via zlint's "higher-scoped configuration" +// mechanism; the unexported field name makes zlint's reflection-based config +// resolver skip the embedded field itself, which it could not deserialize. +type globalNamespace = lint.Global //nolint:unused // Used in SharedConfig. + +// SharedConfig is the lint configuration shared by every CP/CPS profile +// lint. Rather than each lint carrying an identical stanza of its own, all of +// them declare a pointer to this struct, which zlint fills from the single +// shared [Global] stanza of the lint configuration. +type SharedConfig struct { + globalNamespace //nolint:unused // Used by zlint, not by us. + // IssuerCertificatePEM must hold the PEM encoding of the Issuing CA's + // certificate, so that the profile rows requiring byte-for-byte + // correspondence with the Issuing CA can be enforced. If it is not + // configured, the CP/CPS profile lints fail. + IssuerCertificatePEM string `toml:"issuer_certificate" comment:"The PEM encoding of the Issuing CA's certificate."` + // ExistingCertificatePEM must hold the PEM encoding of the existing CA + // Certificate upon which a cross-certificate confers a second issuance + // path. It is read only by the cross-certified subordinate CA profile + // lint, and only the ceremony tool ever configures it, because only the + // ceremony tool issues cross-certificates. + ExistingCertificatePEM string `toml:"existing_certificate" comment:"The PEM encoding of the existing CA Certificate being cross-signed."` +} + +// issuerPEM returns the configured Issuing CA certificate PEM, or the empty +// string if the receiver was never configured. +func (c *SharedConfig) issuerPEM() string { //nolint:unused // Will be used in a followup PR. + if c == nil { + return "" + } + return c.IssuerCertificatePEM +} + +// existingPEM returns the configured existing CA certificate PEM, or the +// empty string if the receiver was never configured. +func (c *SharedConfig) existingPEM() string { //nolint:unused // Will be used in a followup PR. + if c == nil { + return "" + } + return c.ExistingCertificatePEM +} From 3655bad59757a786ccbffb982afb96161299bb79 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 28 Jul 2026 17:08:36 -0700 Subject: [PATCH 16/18] Fix go.mod --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index b64ab28e851..3a38c0bdbfb 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/miekg/dns v1.1.62 github.com/miekg/pkcs11 v1.1.2 github.com/nxadm/tail v1.4.11 + github.com/pelletier/go-toml v1.9.5 github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_model v0.6.1 github.com/redis/go-redis/extra/redisotel/v9 v9.5.3 @@ -76,7 +77,6 @@ require ( github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pelletier/go-toml v1.9.5 // indirect github.com/poy/onpar v1.1.2 // indirect github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect From 5e66c94ea71f57cd7bbe030ab6509bd49bfe6601 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 28 Jul 2026 17:16:12 -0700 Subject: [PATCH 17/18] Fix accidental removal of cpcps import --- linter/linter.go | 1 + 1 file changed, 1 insertion(+) diff --git a/linter/linter.go b/linter/linter.go index 6395b740cf3..4e12c9f934b 100644 --- a/linter/linter.go +++ b/linter/linter.go @@ -16,6 +16,7 @@ import ( _ "github.com/letsencrypt/boulder/linter/lints/cabf_br" _ "github.com/letsencrypt/boulder/linter/lints/chrome" + _ "github.com/letsencrypt/boulder/linter/lints/cpcps" _ "github.com/letsencrypt/boulder/linter/lints/rfc" ) From cd88aee3407ec9bc2884c4807dda4c617e837224 Mon Sep 17 00:00:00 2001 From: Aaron Gable Date: Tue, 28 Jul 2026 17:25:25 -0700 Subject: [PATCH 18/18] Review comments --- linter/lints/cpcps/helpers.go | 8 ++++---- ...ross_certified_subordinate_ca_certificate.go | 17 ++--------------- ...certified_subordinate_ca_certificate_test.go | 6 +++--- linter/lints/cpcps/lint_precertificate.go | 2 +- linter/lints/cpcps/lint_root_ca_certificate.go | 4 +--- .../cpcps/lint_subscriber_server_certificate.go | 8 +++----- .../lint_subscriber_server_certificate_test.go | 6 +++--- .../lint_tls_subordinate_ca_certificate.go | 6 ++---- .../lint_tls_subordinate_ca_certificate_test.go | 6 +++--- 9 files changed, 22 insertions(+), 41 deletions(-) diff --git a/linter/lints/cpcps/helpers.go b/linter/lints/cpcps/helpers.go index 8d225134356..992050fbf67 100644 --- a/linter/lints/cpcps/helpers.go +++ b/linter/lints/cpcps/helpers.go @@ -73,8 +73,8 @@ var ( // package can emit configuration using these keys. const ( // GlobalConfigNamespace is the name of the TOML stanza from which - // IssuingCAConfig is deserialized. It must match the namespace of zlint's - // lint.Global higher-scoped configuration, which IssuingCAConfig embeds. + // SharedConfig is deserialized. It must match the namespace of zlint's + // lint.Global higher-scoped configuration, which SharedConfig embeds. GlobalConfigNamespace = "Global" // IssuerCertificateConfigKey configures the Issuing CA's certificate. IssuerCertificateConfigKey = "issuer_certificate" @@ -83,9 +83,9 @@ const ( ExistingCertificateConfigKey = "existing_certificate" ) -// globalNamespace aliases zlint's lint.Global so that IssuingCAConfig can +// globalNamespace aliases zlint's lint.Global so that SharedConfig can // embed it under an unexported field name. The promoted (unexported) -// namespace method is what routes deserialization of IssuingCAConfig to the +// namespace method is what routes deserialization of SharedConfig to the // shared [Global] stanza, via zlint's "higher-scoped configuration" // mechanism; the unexported field name makes zlint's reflection-based config // resolver skip the embedded field itself, which it could not deserialize. diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go index 077e8270f00..e93e995f5dd 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -21,7 +21,7 @@ import ( type crossCertifiedSubordinateCACertificateMatchesCPSProfile struct { // Config is filled from the shared [Global] stanza of the lint - // configuration; see IssuingCAConfig. + // configuration; see SharedConfig. Config *SharedConfig } @@ -76,9 +76,7 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1019 // |     `version` | See [Section 7.1.1](#711-version-numbers) | - // Section 7.1.1 says "All certificates use X.509 version 3". Note that - // unlike crypto/x509, zcrypto's Version field is one-indexed: it holds 3 - // (not the raw encoded value 2) for a v3 certificate. + // Section 7.1.1 says "All certificates use X.509 version 3". if c.Version != 3 { return errResult("version is not v3") } @@ -126,20 +124,9 @@ func (l *crossCertifiedSubordinateCACertificateMatchesCPSProfile) Execute(c *x50 // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1024 // |     `subject` | Byte-for-byte identical to the `subject` field of the existing CA Certificate | - // The shape checks below are implied by the byte-for-byte comparison, but - // produce more useful error messages. if !bytes.Equal(c.RawSubject, existing.RawSubject) { return errResult("subject is not byte-for-byte identical to the subject of the configured existing CA Certificate") } - if len(c.Subject.Names) != 3 { - return errResult("subject does not contain exactly C, O, and CN attributes") - } - if len(c.Subject.Country) != 1 || c.Subject.Country[0] != "US" { - return errResult("subject countryName is not US") - } - if c.Subject.CommonName == "" { - return errResult("subject commonName is empty") - } // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1025 // |     `subjectPublicKeyInfo` | See Sections [6.1.5](#615-key-sizes), [6.1.6](#616-public-key-parameters-generation-and-quality-checking), and [7.1.3.1](#7131-subjectpublickeyinfo) | diff --git a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go index 2b9cf109493..a982e128157 100644 --- a/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go @@ -154,7 +154,7 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { wantSubStr: "authorityInformationAccess caIssuers URI is not an http URL", }, { - name: "aia_unparseable", + name: "aia_unparsable", mod: func(t *testing.T, tmpl *x509.Certificate) { tmpl.IssuingCertificateURL = []string{"http://x99.i.lencr.org/%zz"} }, @@ -186,7 +186,7 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { wantSubStr: "crlDistributionPoints URI is not an http URL", }, { - name: "crldp_unparseable", + name: "crldp_unparsable", mod: func(t *testing.T, tmpl *x509.Certificate) { tmpl.CRLDistributionPoints = []string{"http://x99.c.lencr.org/%zz"} }, @@ -302,7 +302,7 @@ func TestCrossCertifiedSubordinateCACertificateMatchesCPSProfile(t *testing.T) { t.Fatalf("creating existing CA certificate: %s", err) } - pub := crypto.PublicKey(crossKey.Public()) + pub := crossKey.Public() if tc.pub != nil { pub = tc.pub } diff --git a/linter/lints/cpcps/lint_precertificate.go b/linter/lints/cpcps/lint_precertificate.go index a39da2f79e8..8101f4b4a3d 100644 --- a/linter/lints/cpcps/lint_precertificate.go +++ b/linter/lints/cpcps/lint_precertificate.go @@ -12,7 +12,7 @@ import ( type precertificateMatchesCPSProfile struct { // Config is filled from the shared [Global] stanza of the lint - // configuration; see IssuingCAConfig. + // configuration; see SharedConfig. Config *SharedConfig } diff --git a/linter/lints/cpcps/lint_root_ca_certificate.go b/linter/lints/cpcps/lint_root_ca_certificate.go index bb96ce27487..7a2f3057e0f 100644 --- a/linter/lints/cpcps/lint_root_ca_certificate.go +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -51,9 +51,7 @@ func (l *rootCACertificateMatchesCPSProfile) CheckApplies(c *x509.Certificate) b func (l *rootCACertificateMatchesCPSProfile) Execute(c *x509.Certificate) *lint.LintResult { // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L997 // |     `version` | See [Section 7.1.1](#711-version-numbers) | - // Section 7.1.1 says "All certificates use X.509 version 3". Note that - // unlike crypto/x509, zcrypto's Version field is one-indexed: it holds 3 - // (not the raw encoded value 2) for a v3 certificate. + // Section 7.1.1 says "All certificates use X.509 version 3". if c.Version != 3 { return errResult("version is not v3") } diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate.go b/linter/lints/cpcps/lint_subscriber_server_certificate.go index 468e8a9eeb7..27c3e66dd0d 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -26,7 +26,7 @@ import ( type subscriberServerCertificateMatchesCPSProfile struct { // Config is filled from the shared [Global] stanza of the lint - // configuration; see IssuingCAConfig. + // configuration; see SharedConfig. Config *SharedConfig } @@ -89,7 +89,7 @@ func (l *subscriberServerCertificateMatchesCPSProfile) Execute(c *x509.Certifica } // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1093 - // |         Any other extensions | Not present | + // |         Any other extension | Not present | extensions := map[string]bool{ authorityInformationAccessOID.String(): false, authorityKeyIdentifierOID.String(): false, @@ -145,9 +145,7 @@ func checkSubscriberProfile(c *x509.Certificate, issuerPEM string) *lint.LintRes // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1073 // |     `version` | See [Section 7.1.1](#711-version-numbers) | - // Section 7.1.1 says "All certificates use X.509 version 3". Note that - // unlike crypto/x509, zcrypto's Version field is one-indexed: it holds 3 - // (not the raw encoded value 2) for a v3 certificate. + // Section 7.1.1 says "All certificates use X.509 version 3". if c.Version != 3 { return errResult("version is not v3") } diff --git a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go index 59bc677b52c..e52b91e7187 100644 --- a/linter/lints/cpcps/lint_subscriber_server_certificate_test.go +++ b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go @@ -228,7 +228,7 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { wantSubStr: "authorityInformationAccess caIssuers URI is not an http URL", }, { - name: "aia_unparseable", + name: "aia_unparsable", mod: func(t *testing.T, tmpl *x509.Certificate) { tmpl.IssuingCertificateURL = []string{"http://e99.i.lencr.org/%zz"} }, @@ -260,7 +260,7 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { wantSubStr: "crlDistributionPoints URI is not an http URL", }, { - name: "crldp_unparseable", + name: "crldp_unparsable", mod: func(t *testing.T, tmpl *x509.Certificate) { tmpl.CRLDistributionPoints = []string{"http://e99.c.lencr.org/%zz"} }, @@ -429,7 +429,7 @@ func TestSubscriberServerCertificateMatchesCPSProfile(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - pub := crypto.PublicKey(testKey(t, elliptic.P256()).Public()) + pub := testKey(t, elliptic.P256()).Public() if tc.pub != nil { pub = tc.pub } diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go index 86db83228cd..cd6c839e5cc 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -23,7 +23,7 @@ import ( type tlsSubordinateCACertificateMatchesCPSProfile struct { // Config is filled from the shared [Global] stanza of the lint - // configuration; see IssuingCAConfig. + // configuration; see SharedConfig. Config *SharedConfig } @@ -81,9 +81,7 @@ func (l *tlsSubordinateCACertificateMatchesCPSProfile) Execute(c *x509.Certifica // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1046 // |     `version` | See [Section 7.1.1](#711-version-numbers) | - // Section 7.1.1 says "All certificates use X.509 version 3". Note that - // unlike crypto/x509, zcrypto's Version field is one-indexed: it holds 3 - // (not the raw encoded value 2) for a v3 certificate. + // Section 7.1.1 says "All certificates use X.509 version 3". if c.Version != 3 { return errResult("version is not v3") } diff --git a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go index d04454b70b0..22c853e9b25 100644 --- a/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go @@ -156,7 +156,7 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { wantSubStr: "authorityInformationAccess caIssuers URI is not an http URL", }, { - name: "aia_unparseable", + name: "aia_unparsable", mod: func(t *testing.T, tmpl *x509.Certificate) { tmpl.IssuingCertificateURL = []string{"http://x99.i.lencr.org/%zz"} }, @@ -188,7 +188,7 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { wantSubStr: "crlDistributionPoints URI is not an http URL", }, { - name: "crldp_unparseable", + name: "crldp_unparsable", mod: func(t *testing.T, tmpl *x509.Certificate) { tmpl.CRLDistributionPoints = []string{"http://x99.c.lencr.org/%zz"} }, @@ -325,7 +325,7 @@ func TestTLSSubordinateCACertificateMatchesCPSProfile(t *testing.T) { if curve == nil { curve = elliptic.P384() } - pub := crypto.PublicKey(testKey(t, curve).Public()) + pub := testKey(t, curve).Public() if tc.pub != nil { pub = tc.pub }