diff --git a/examples/config.dist.json b/examples/config.dist.json index 7863d047..eada6aad 100644 --- a/examples/config.dist.json +++ b/examples/config.dist.json @@ -9,7 +9,8 @@ "interval": "600s", "timeout": "500s", "certIssuerKeyPath": "/etc/ct-woodpecker/issuer.key", - "certIssuerPath": "/etc/ct-woodpecker/issuer.pem" + "certIssuerPath": "/etc/ct-woodpecker/issuer.pem", + "baseDomain": ".example.com" }, "inclusionConfig": { "interval": "60s", diff --git a/monitor/cert_submitter.go b/monitor/cert_submitter.go index d05925c2..3fcc2dda 100644 --- a/monitor/cert_submitter.go +++ b/monitor/cert_submitter.go @@ -89,6 +89,12 @@ type SubmitterOptions struct { // be constrained within the provided window. WindowStart *time.Time WindowEnd *time.Time + // BaseDomain is the domain suffix used for the subject common name of + // submitted certificates in combination with the first bytes of the + // random certificate serial. If empty the default value + // `.woodpecker.testing.letsencrypt.org` is used. The BaseDomain must begin + // with a '.' character. + BaseDomain string } // Valid checks that the SubmitterOptions has a valid positive interval and that @@ -114,6 +120,10 @@ func (o SubmitterOptions) Valid() error { return errors.New("WindowEnd must be after WindowStart") } + if o.BaseDomain != "" && o.BaseDomain[0] != '.' { + return errors.New("BaseDomain must start with a '.' character") + } + return nil } @@ -146,6 +156,9 @@ type certSubmitter struct { // window. windowStart *time.Time windowEnd *time.Time + // Base domain that used as the suffix for the submitted certificate subject + // common name. If empty a default domain suffix is used. + baseDomain string } func newCertSubmitter( @@ -168,6 +181,7 @@ func newCertSubmitter( resubmitIncluded: opts.ResubmitIncluded, windowStart: opts.WindowStart, windowEnd: opts.WindowEnd, + baseDomain: opts.BaseDomain, } } @@ -204,7 +218,13 @@ func (c *certSubmitter) submitCertificates() { panic("certSubmitter created with nil certIssuerKey or certIssuer\n") } - certPair, err := pki.IssueTestCertificate(c.certIssuerKey, c.certIssuer, c.clk, c.windowStart, c.windowEnd) + certPair, err := pki.IssueTestCertificate( + c.baseDomain, + c.certIssuerKey, + c.certIssuer, + c.clk, + c.windowStart, + c.windowEnd) if err != nil { // This should not occur and if it does we should abort hard panic(fmt.Sprintf("!!! Error issuing certificate: %s\n", err.Error())) diff --git a/monitor/cert_submitter_test.go b/monitor/cert_submitter_test.go index a3a077a1..ddfabcbc 100644 --- a/monitor/cert_submitter_test.go +++ b/monitor/cert_submitter_test.go @@ -373,6 +373,31 @@ func TestSubmitterOptions(t *testing.T) { WindowEnd: &goodEnd, }, }, + { + Name: "Invalid base domain", + Opts: SubmitterOptions{ + Interval: time.Second * 10, + Timeout: time.Second * 10, + IssuerKey: k, + IssuerCert: cert, + WindowStart: &goodStart, + WindowEnd: &goodEnd, + BaseDomain: "aaaa", + }, + ExpectedError: "BaseDomain must start with a '.' character", + }, + { + Name: "Good base domain", + Opts: SubmitterOptions{ + Interval: time.Second * 10, + Timeout: time.Second * 10, + IssuerKey: k, + IssuerCert: cert, + WindowStart: &goodStart, + WindowEnd: &goodEnd, + BaseDomain: ".my.base.domain.example.com", + }, + }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { diff --git a/pki/certs.go b/pki/certs.go index 7c48a8cf..165339ca 100644 --- a/pki/certs.go +++ b/pki/certs.go @@ -20,9 +20,11 @@ import ( ) const ( - // Domain suffix for the subject common name of certificates generated for submission - // to logs. The prefix will be generated randomly from the certificate serial number. - testCertDomain = ".woodpecker.testing.letsencrypt.org" + // defaultTestCertDomain is the Domain suffix for the subject common name of + // certificates generated for submission to logs unless customized in + // ct-woodpecker config. The prefix will be generated randomly from the + // certificate serial number. + defaultTestCertDomain = ".woodpecker.testing.letsencrypt.org" ) var ( @@ -30,6 +32,7 @@ var ( errNilIssuerKey = errors.New("cannot IssueCertificate with nil issuerKey") errNilIssuerCert = errors.New("cannot IssueCertificate with nil issuerCert") errNilTemplate = errors.New("cannot IssueCertificate with nil template") + errBadBaseDomain = errors.New("baseDomain must start with '.' to be used as a domain prefix") ctPoisonExtensionID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 4, 3} ctPoisonExtension = pkix.Extension{ @@ -101,7 +104,8 @@ type CertificatePair struct { // IssueTestCertificate uses the monitor's certIssuer and certIssuerKey to generate // a precertificate and a matching final leaf-certificate that can be submitted // to a log. The certificate's subject common name will be a random subdomain -// based on the certificate serial under the `testCertDomain` domain. +// based on the certificate serial under the provided baseDomain (or +// defaultTestCertDomain domain if baseDomain is empty). // // If windowStart is nil the certificate NotBefore will be set to the current // time based on the provided clock. If windowStart is not nil then the @@ -115,11 +119,18 @@ type CertificatePair struct { // so while they are not issued by a trusted root we try to avoid cablint // errors to avoid requiring log monitors special-case our submissions. func IssueTestCertificate( + baseDomain string, issuerKey *ecdsa.PrivateKey, issuerCert *x509.Certificate, clk clock.Clock, windowStart *time.Time, windowEnd *time.Time) (CertificatePair, error) { + if baseDomain == "" { + baseDomain = defaultTestCertDomain + } + if baseDomain[0] != '.' { + return CertificatePair{}, errBadBaseDomain + } certKey, err := RandKey() if err != nil { @@ -142,7 +153,7 @@ func IssueTestCertificate( latest.AddDate(0, 0, -1) } - domain := hex.EncodeToString(serial.Bytes()[:5]) + testCertDomain + domain := hex.EncodeToString(serial.Bytes()[:5]) + baseDomain issueLeafCert := func(precert bool) (*x509.Certificate, error) { tmpl := &x509.Certificate{ @@ -157,8 +168,8 @@ func IssueTestCertificate( ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, BasicConstraintsValid: true, IsCA: false, - IssuingCertificateURL: []string{"http://issuer" + testCertDomain}, - CRLDistributionPoints: []string{"http://crls" + testCertDomain}, + IssuingCertificateURL: []string{"http://issuer" + baseDomain}, + CRLDistributionPoints: []string{"http://crls" + baseDomain}, } if precert { tmpl.ExtraExtensions = []pkix.Extension{ctPoisonExtension} diff --git a/pki/certs_test.go b/pki/certs_test.go index 455c5196..3610a05d 100644 --- a/pki/certs_test.go +++ b/pki/certs_test.go @@ -5,6 +5,7 @@ import ( "crypto/ecdsa" "crypto/x509" "encoding/hex" + "strings" "testing" "time" @@ -69,7 +70,7 @@ func TestIssueTestCertificate(t *testing.T) { issuerCert := &x509.Certificate{} clk := clock.New() - certPair, err := IssueTestCertificate(issuerKey, issuerCert, clk, nil, nil) + certPair, err := IssueTestCertificate("", issuerKey, issuerCert, clk, nil, nil) if err != nil { t.Fatalf("unexpected error from IssueTestCertificate: %s", err.Error()) } @@ -94,7 +95,7 @@ func TestIssueTestCertificate(t *testing.T) { t.Errorf("SerialNumbers of CertPair did not match") } - expectedDomain := hex.EncodeToString(certPair.PreCert.SerialNumber.Bytes()[:5]) + testCertDomain + expectedDomain := hex.EncodeToString(certPair.PreCert.SerialNumber.Bytes()[:5]) + defaultTestCertDomain if certPair.PreCert.Subject.CommonName != expectedDomain { t.Errorf("PreCert had wrong CommonName. Expected %q, had %q", expectedDomain, certPair.PreCert.Subject.CommonName) @@ -131,7 +132,7 @@ func TestIssueTestCertificate(t *testing.T) { t.Errorf("Cert had wrong KeyUsage. Expected %#v, found %#v", x509.KeyUsageDigitalSignature, certPair.Cert.KeyUsage) } - expectedIssuerURL := "http://issuer" + testCertDomain + expectedIssuerURL := "http://issuer" + defaultTestCertDomain if len(certPair.PreCert.IssuingCertificateURL) != 1 || certPair.PreCert.IssuingCertificateURL[0] != expectedIssuerURL { t.Errorf("PreCert had wrong IssuingCertificateURL. Expected [%q], found %#v", expectedIssuerURL, certPair.PreCert.IssuingCertificateURL) } @@ -139,7 +140,7 @@ func TestIssueTestCertificate(t *testing.T) { t.Errorf("Cert had wrong IssuingCertificateURL. Expected [%q], found %#v", expectedIssuerURL, certPair.Cert.IssuingCertificateURL) } - expectedCRLURL := "http://crls" + testCertDomain + expectedCRLURL := "http://crls" + defaultTestCertDomain if len(certPair.PreCert.CRLDistributionPoints) != 1 || certPair.PreCert.CRLDistributionPoints[0] != expectedCRLURL { t.Errorf("PreCert had wrong CRLDistributionPoints. Expected [%q], found %#v", expectedCRLURL, certPair.PreCert.CRLDistributionPoints) } @@ -170,7 +171,7 @@ func TestIssueTestCertificateWindow(t *testing.T) { clk := clock.New() // Issue a cert pair with nil WindowStart and WindowEnd - certPair, err := IssueTestCertificate(issuerKey, issuerCert, clk, nil, nil) + certPair, err := IssueTestCertificate("", issuerKey, issuerCert, clk, nil, nil) if err != nil { t.Fatalf("unexpected error from IssueTestCertificate: %s", err.Error()) } @@ -217,7 +218,7 @@ func TestIssueTestCertificateWindow(t *testing.T) { windowEnd, _ := time.Parse(time.RFC3339, "2001-01-01T00:00:00Z") // Issue a cert pair with specific WindowStart and WindowEnd - certPair, err = IssueTestCertificate(issuerKey, issuerCert, clk, &windowStart, &windowEnd) + certPair, err = IssueTestCertificate("", issuerKey, issuerCert, clk, &windowStart, &windowEnd) if err != nil { t.Fatalf("unexpected error from IssueTestCertificate: %s", err.Error()) } @@ -256,3 +257,55 @@ func TestIssueTestCertificateWindow(t *testing.T) { notAfter, expectedEndDate) } } + +func TestIssueTestCertificateBaseDomain(t *testing.T) { + issuerKey, _ := RandKey() + issuerCert := &x509.Certificate{} + clk := clock.New() + + testCases := []struct { + name string + baseName string + expectedErr string + }{ + { + name: "invalid base name", + baseName: "example.com", + expectedErr: "baseDomain must start with '.' to be used as a domain prefix", + }, + { + name: "default base name", + }, + { + name: "custom base name", + baseName: ".custom.example.com", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + certPair, err := IssueTestCertificate(tc.baseName, issuerKey, issuerCert, clk, nil, nil) + + if tc.expectedErr == "" && err != nil { + t.Errorf("unexpected error from IssueTestCertificate: %s", err.Error()) + } else if tc.expectedErr != "" && err == nil { + t.Errorf("expected err %q got nil", tc.expectedErr) + } else if tc.expectedErr != "" && err != nil { + if actual := err.Error(); actual != tc.expectedErr { + t.Errorf("expected err %q got %q", tc.expectedErr, actual) + } + } else { + expected := tc.baseName + if expected == "" { + expected = defaultTestCertDomain + } + if certPair.Cert == nil { + t.Fatalf("unexpected nil Cert in CertPair returned from IssueTestCertificate") + } + if !strings.HasSuffix(certPair.Cert.Subject.CommonName, expected) { + t.Errorf("expected cert to have subj. CN suffix %q, was %q", expected, certPair.Cert.Subject.CommonName) + } + } + }) + } +} diff --git a/test/config/ct-woodpecker.localdev.json b/test/config/ct-woodpecker.localdev.json index 1576576b..2bce856a 100644 --- a/test/config/ct-woodpecker.localdev.json +++ b/test/config/ct-woodpecker.localdev.json @@ -10,7 +10,8 @@ "interval": "5s", "timeout": "5s", "certIssuerKeyPath": "test/issuer.key", - "certIssuerPath": "test/issuer.pem" + "certIssuerPath": "test/issuer.pem", + "baseDomain": ".local.dev" }, "inclusionConfig": { "interval": "30s",