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..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) } @@ -534,30 +551,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,11 +599,11 @@ 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) } - 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 } @@ -596,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 } @@ -616,7 +631,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,12 +643,12 @@ 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) } 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 } @@ -648,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 } @@ -668,7 +683,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,12 +699,12 @@ 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) } 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 } @@ -750,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 } @@ -770,7 +785,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..c6cd04364fa 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") } @@ -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/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/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 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..4e12c9f934b 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,14 +103,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) } 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..992050fbf67 --- /dev/null +++ b/linter/lints/cpcps/helpers.go @@ -0,0 +1,214 @@ +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" + "math/big" + + "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 ( + // 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, + // sha384WithRSAEncryption + "300d06092a864886f70d01010c0500": true, + // sha512WithRSAEncryption + "300d06092a864886f70d01010d0500": true, + // ecdsa-with-SHA256 + "300a06082a8648ce3d040302": true, + // ecdsa-with-SHA384 + "300a06082a8648ce3d040303": true, + // ecdsa-with-SHA512 + "300a06082a8648ce3d040304": true, + } + + // 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" + 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 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 + // 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" + // ExistingCertificateConfigKey configures the pre-existing certificate of + // a CA being cross-signed. + ExistingCertificateConfigKey = "existing_certificate" +) + +// 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 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. +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 +} + +// 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 { + 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..7d90c85e633 --- /dev/null +++ b/linter/lints/cpcps/helpers_test.go @@ -0,0 +1,608 @@ +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, + // 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, + 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, + // 1098 days, inclusive of the final second. + NotAfter: notBefore.AddDate(0, 0, 1098).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}), + }, + } +} + +// 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() + 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..e93e995f5dd --- /dev/null +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate.go @@ -0,0 +1,389 @@ +package cpcps + +import ( + "bytes" + "crypto/ecdh" + "crypto/elliptic" + "encoding/hex" + "fmt" + "math/big" + "net/url" + "time" + + "github.com/weppos/publicsuffix-go/publicsuffix" + 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 SharedConfig. + 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/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 + // 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)") + } + + // 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". + if c.Version != 3 { + return errResult("version is not v3") + } + + // 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") + } + if c.SerialNumber.BitLen() <= 100 { + return errResult("serialNumber is not more than 100 bits long") + } + + // 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") + } + if !brSignatureAlgorithmIdentifiers[hex.EncodeToString(tbsSignature)] { + return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") + } + + // 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") + } + + // 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") + } + if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 1098*lints.BRDay { + return errResult("validity is more than 1098 days") + } + + // 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 | + 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") + } + + // 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") + } + switch key := c.PublicKey.(type) { + case *zrsa.PublicKey: + // 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") + } + // 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)) + } + 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)) + } + 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. + // 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") + } + default: + return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) + } + + // 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") + } + + // 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") + } + + // 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). + 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") + } + if len(c.IssuingCertificateURL) != 1 { + return errResult("authorityInformationAccess does not contain exactly one caIssuers entry") + } + 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") + } + + // 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") + } + 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") + } + + // 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") + } + 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") + } + + // 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) | + 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") + } + + // 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. + 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") + } + 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") + } + + // 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) | + 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#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") + } + 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") + } + + // 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") + } + 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") + } + + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1037 + // |         Any other extension | 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, + subjectKeyIdentifierOID.String(): false, + } + for _, ext := range c.Extensions { + 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 + // | `signatureAlgorithm` | Byte-for-byte identical to the `tbsCertificate.signature` | + 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") + } + + // 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_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..a982e128157 --- /dev/null +++ b/linter/lints/cpcps/lint_cross_certified_subordinate_ca_certificate_test.go @@ -0,0 +1,360 @@ +package cpcps + +import ( + "crypto" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "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, + // 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. + 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 + pub crypto.PublicKey + mod func(t *testing.T, tmpl *x509.Certificate) + want lint.LintStatus + wantSubStr string + }{ + { + 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) { + // 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) { + // 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 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) { + 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", + }, + { + 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_unparsable", + 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_unparsable", + 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", + }, + { + // 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", + }, + { + // 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 { + 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) + } + + pub := 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, pub, 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..8101f4b4a3d --- /dev/null +++ b/linter/lints/cpcps/lint_precertificate.go @@ -0,0 +1,104 @@ +package cpcps + +import ( + "fmt" + + "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 SharedConfig. + 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: +// 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 + } + + // 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") + } + if !poisonExt.Critical { + return errResult("CT poison extension is not critical") + } + + // 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. + 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 { + 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 new file mode 100644 index 00000000000..0f3543b819f --- /dev/null +++ b/linter/lints/cpcps/lint_precertificate_test.go @@ -0,0 +1,155 @@ +package cpcps + +import ( + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "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: "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: "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 { + 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 index a963cf1958f..d827e518cfc 100644 --- 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 @@ -15,11 +15,12 @@ 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, + 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, }) 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..7a2f3057e0f --- /dev/null +++ b/linter/lints/cpcps/lint_root_ca_certificate.go @@ -0,0 +1,276 @@ +package cpcps + +import ( + "bytes" + "crypto/ecdh" + "crypto/elliptic" + stdx509 "crypto/x509" + "encoding/hex" + "fmt" + "math/big" + "time" + + 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/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L992 +// ### Root CA Certificate Profile +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". + if c.Version != 3 { + return errResult("version is not v3") + } + + // 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") + } + if c.SerialNumber.BitLen() <= 100 { + return errResult("serialNumber is not more than 100 bits long") + } + + // 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") + } + if !brSignatureAlgorithmIdentifiers[hex.EncodeToString(tbsSignature)] { + return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") + } + + // 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") + } + + // 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") + } + if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 3660*lints.BRDay { + return errResult("validity is more than 3660 days") + } + + // 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. + 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") + } + + // 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") + } + switch key := c.PublicKey.(type) { + case *zrsa.PublicKey: + // 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") + } + // 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)) + } + 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)) + } + 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. + // 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") + } + default: + return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) + } + + // 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") + } + + // 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") + } + + // 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") + } + if !bcExt.Critical { + return errResult("basicConstraints extension is not critical") + } + if !c.IsCA { + return errResult("basicConstraints cA is not true") + } + + // 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") + } + 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") + } + + // 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") + } + 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") + } + + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1010 + // |         Any other extension | Not present | + extensions := map[string]bool{ + basicConstraintsOID.String(): false, + keyUsageOID.String(): false, + subjectKeyIdentifierOID.String(): false, + } + for _, ext := range c.Extensions { + 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 + // | `signatureAlgorithm` | Byte-for-byte identical to the `tbsCertificate.signature` | + 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") + } + + // 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_root_ca_certificate_test.go b/linter/lints/cpcps/lint_root_ca_certificate_test.go new file mode 100644 index 00000000000..5b214d77706 --- /dev/null +++ b/linter/lints/cpcps/lint_root_ca_certificate_test.go @@ -0,0 +1,209 @@ +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.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, + }, + { + 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: "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 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) { + 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", + }, + { + 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 { + 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 index fdf5906c984..b053b817537 100644 --- 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 @@ -15,11 +15,12 @@ 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, + 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, }) 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 index e91e187c41e..7f0deb2fa51 100644 --- 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 @@ -15,11 +15,12 @@ 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, + 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, }) 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..27c3e66dd0d --- /dev/null +++ b/linter/lints/cpcps/lint_subscriber_server_certificate.go @@ -0,0 +1,490 @@ +package cpcps + +import ( + "bytes" + "crypto/ecdh" + "crypto/elliptic" + stdx509 "crypto/x509" + "encoding/hex" + "fmt" + "math/big" + "net" + "net/url" + "slices" + "time" + + "github.com/weppos/publicsuffix-go/publicsuffix" + 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 SharedConfig. + 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/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 + } + + // 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. + 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 + } + if len(logIDs) < 2 { + return errResult("signedCertificateTimestampList does not contain SCTs from at least two distinct logs") + } + + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1093 + // |         Any other extension | 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 { + 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} +} + +// 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/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. + 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)") + } + + // 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". + if c.Version != 3 { + return errResult("version is not v3") + } + + // 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") + } + if c.SerialNumber.BitLen() <= 100 { + return errResult("serialNumber is not more than 100 bits long") + } + + // 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") + } + if !brSignatureAlgorithmIdentifiers[hex.EncodeToString(tbsSignature)] { + return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") + } + + // 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") + } + + // 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") + } + if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 100*lints.BRDay { + return errResult("validity is more than 100 days") + } + + // 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 | + 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())) + } + 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 { + 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, cn) { + return errResult("subject commonName is not one of the subjectAltName dNSName values") + } + } + + // 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") + } + switch key := c.PublicKey.(type) { + case *zrsa.PublicKey: + // 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") + } + // 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)) + } + 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. + // 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") + } + default: + return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) + } + + // 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") + } + + // 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") + } + + // 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). + 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") + } + if len(c.IssuingCertificateURL) != 1 { + return errResult("authorityInformationAccess does not contain exactly one caIssuers entry") + } + 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") + } + + // 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") + } + 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") + } + + // 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") + } + if !bcExt.Critical { + return errResult("basicConstraints extension is not critical") + } + if c.IsCA { + return errResult("basicConstraints cA is not false") + } + + // 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) | + 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") + } + + // 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. + 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") + } + 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") + } + + // 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) | + 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 optionally the `keyEncipherment` (2) bit, for RSA keys) set | + 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)") + } + + // 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") + } + 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") + } + + // 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 { + 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") + } + } + + // 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()) + } + if !bytes.Equal(tbsSignature, signatureAlgorithm) { + return errResult("signatureAlgorithm is not byte-for-byte identical to the tbsCertificate.signature") + } + + // 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_subscriber_server_certificate_test.go b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go new file mode 100644 index 00000000000..e52b91e7187 --- /dev/null +++ b/linter/lints/cpcps/lint_subscriber_server_certificate_test.go @@ -0,0 +1,489 @@ +package cpcps + +import ( + "crypto" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "fmt" + "math/big" + "net" + "strings" + "testing" + "time" + + "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 + pub crypto.PublicKey + 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: "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: "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: "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: "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) { + 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: "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_unparsable", + 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_unparsable", + 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", + }, + { + // 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", + }, + { + // 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) { + 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() + + pub := 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, pub) + } + if tc.mod != nil { + tc.mod(t, tmpl) + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, intTmpl, pub, 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..cd6c839e5cc --- /dev/null +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate.go @@ -0,0 +1,406 @@ +package cpcps + +import ( + "bytes" + "crypto/ecdh" + "crypto/elliptic" + stdx509 "crypto/x509" + "encoding/hex" + "fmt" + "math/big" + "net/url" + "time" + + "github.com/weppos/publicsuffix-go/publicsuffix" + 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 SharedConfig. + 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/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. + 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)") + } + + // 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". + if c.Version != 3 { + return errResult("version is not v3") + } + + // 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") + } + if c.SerialNumber.BitLen() <= 100 { + return errResult("serialNumber is not more than 100 bits long") + } + + // 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") + } + if !brSignatureAlgorithmIdentifiers[hex.EncodeToString(tbsSignature)] { + return errResult("signature is not byte-for-byte identical to a BRs Section 7.1.3.2 encoding") + } + + // 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") + } + + // 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") + } + if c.NotAfter.Add(time.Second).Sub(c.NotBefore) > 1098*lints.BRDay { + return errResult("validity is more than 1098 days") + } + + // 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. + 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") + } + + // 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") + } + switch key := c.PublicKey.(type) { + case *zrsa.PublicKey: + // 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") + } + // 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)) + } + 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)) + } + 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. + // 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") + } + default: + return errResult(fmt.Sprintf("unsupported public key type %T", c.PublicKey)) + } + + // 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") + } + + // 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") + } + + // 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). + 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") + } + if len(c.IssuingCertificateURL) != 1 { + return errResult("authorityInformationAccess does not contain exactly one caIssuers entry") + } + 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") + } + + // 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") + } + 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") + } + + // 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") + } + 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") + } + + // 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) | + 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") + } + + // 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. + 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") + } + 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") + } + + // 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) | + 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#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") + } + 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") + } + + // 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") + } + 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") + } + + // https://github.com/letsencrypt/cp-cps/blob/TKTK-replace-with-version-tag/CP-CPS.md?plain=1#L1064 + // |         Any other extension | 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, + subjectKeyIdentifierOID.String(): false, + } + for _, ext := range c.Extensions { + 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 + // | `signatureAlgorithm` | Byte-for-byte identical to the `tbsCertificate.signature` | + 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") + } + + // 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} +} 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..22c853e9b25 --- /dev/null +++ b/linter/lints/cpcps/lint_tls_subordinate_ca_certificate_test.go @@ -0,0 +1,389 @@ +package cpcps + +import ( + "crypto" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "strings" + "testing" + "time" + + "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 + pub crypto.PublicKey + 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) { + // 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 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: "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: "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 is not an http URL", + }, + { + name: "aia_unparsable", + 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_unparsable", + 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", + }, + { + // 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", + }, + { + // 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) { + 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() + } + pub := testKey(t, curve).Public() + if tc.pub != nil { + pub = tc.pub + } + + tmpl := testIntermediateTemplate(t, pub) + if tc.mod != nil { + tc.mod(t, tmpl) + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, rootTmpl, pub, 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") + } +}