//go:build integration package integration import ( "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/x509" "database/sql" "errors" "fmt" "os" "os/exec" "strings" "testing" "time" _ "github.com/go-sql-driver/mysql" "github.com/letsencrypt/boulder/core" "github.com/letsencrypt/boulder/sa" "github.com/letsencrypt/boulder/test" ocsp_helper "github.com/letsencrypt/boulder/test/ocsp/helper" "github.com/letsencrypt/boulder/test/vars" "golang.org/x/crypto/ocsp" ) // getPrecertByName finds and parses a precertificate using the given hostname. // It returns the most recent one. func getPrecertByName(db *sql.DB, name string) (*x509.Certificate, error) { name = sa.ReverseName(name) // Find the certificate from the precertificates table. We don't know the serial so // we have to look it up by name. var der []byte rows, err := db.Query(` SELECT der FROM issuedNames JOIN precertificates USING (serial) WHERE reversedName = ? ORDER BY issuedNames.id DESC LIMIT 1 `, name) for rows.Next() { err = rows.Scan(&der) if err != nil { return nil, err } } if der == nil { return nil, fmt.Errorf("no precertificate found for %q", name) } cert, err := x509.ParseCertificate(der) if err != nil { return nil, err } return cert, nil } // expectOCSP500 queries OCSP for the given certificate and expects a 500 error. func expectOCSP500(cert *x509.Certificate) error { _, err := ocsp_helper.Req(cert, ocsp_helper.DefaultConfig) if err == nil { return errors.New("Expected error getting OCSP for certificate that failed status storage") } var statusCodeError ocsp_helper.StatusCodeError if !errors.As(err, &statusCodeError) { return fmt.Errorf("Got wrong kind of error for OCSP. Expected status code error, got %s", err) } else if statusCodeError.Code != 500 { return fmt.Errorf("Got wrong error status for OCSP. Expected 500, got %d", statusCodeError.Code) } return nil } // TestIssuanceCertStorageFailed tests what happens when a storage RPC fails // during issuance. Specifically, it tests that case where we successfully // prepared and stored a linting certificate plus metadata, but after // issuing the precertificate we failed to mark the certificate as "ready" // to serve an OCSP "good" response. // // To do this, we need to mess with the database, because we want to cause // a failure in one specific query, without control ever returning to the // client. Fortunately we can do this with MySQL triggers. // // We also want to make sure we can revoke the precertificate, which we will // assume exists (note that this different from the root program assumption // that a final certificate exists for any precertificate, though it is // similar in spirit). func TestIssuanceCertStorageFailed(t *testing.T) { t.Parallel() os.Setenv("DIRECTORY", "http://boulder.service.consul:4001/directory") ctx := context.Background() // This test is gated on the StoreLintingCertificateInsteadOfPrecertificate // feature flag. if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" { t.Skip("Skipping test because it requires the StoreLintingCertificateInsteadOfPrecertificate feature flag") } db, err := sql.Open("mysql", vars.DBConnSAIntegrationFullPerms) test.AssertNotError(t, err, "failed to open db connection") _, err = db.ExecContext(ctx, `DROP TRIGGER IF EXISTS fail_ready`) test.AssertNotError(t, err, "failed to drop trigger") // Make a specific update to certificateStatus fail, for this test but not others. // To limit the effect to this one test, we make the trigger aware of a specific // hostname used in this test. Since the UPDATE to the certificateStatus table // doesn't include the hostname, we look it up in the issuedNames table, keyed // off of the serial being updated. // We limit this to UPDATEs that set the status to "good" because otherwise we // would fail to revoke the certificate later. // NOTE: CREATE and DROP TRIGGER do not work in prepared statements. Go's // database/sql will automatically try to use a prepared statement if you pass // any arguments to Exec besides the query itself, so don't do that. _, err = db.ExecContext(ctx, ` CREATE TRIGGER fail_ready BEFORE UPDATE ON certificateStatus FOR EACH ROW BEGIN DECLARE reversedName1 VARCHAR(255); SELECT reversedName INTO reversedName1 FROM issuedNames WHERE serial = NEW.serial AND reversedName LIKE "com.wantserror.%"; IF NEW.status = "good" AND reversedName1 != "" THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Pretend there was an error updating the certificateStatus'; END IF; END `) test.AssertNotError(t, err, "failed to create trigger") defer db.ExecContext(ctx, `DROP TRIGGER IF EXISTS fail_ready`) certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "creating random cert key") // ---- Test revocation by serial ---- revokeMeDomain := "revokeme.wantserror.com" // This should fail because the trigger prevented setting the certificate status to "ready" _, err = authAndIssue(nil, certKey, []string{revokeMeDomain}, true) test.AssertError(t, err, "expected authAndIssue to fail") cert, err := getPrecertByName(db, revokeMeDomain) test.AssertNotError(t, err, "failed to get certificate by name") err = expectOCSP500(cert) test.AssertNotError(t, err, "expected 500 error from OCSP") // Revoke by invoking admin-revoker config := fmt.Sprintf("%s/%s", os.Getenv("BOULDER_CONFIG_DIR"), "admin-revoker.json") output, err := exec.Command("./bin/admin-revoker", "serial-revoke", "-config", config, core.SerialToString(cert.SerialNumber), "0").CombinedOutput() test.AssertNotError(t, err, fmt.Sprintf("revoking via admin-revoker: %s", string(output))) _, err = ocsp_helper.Req(cert, ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.Unspecified)) // ---- Test revocation by key ---- blockMyKeyDomain := "blockmykey.wantserror.com" // This should fail because the trigger prevented setting the certificate status to "ready" _, err = authAndIssue(nil, certKey, []string{blockMyKeyDomain}, true) test.AssertError(t, err, "expected authAndIssue to fail") cert, err = getPrecertByName(db, blockMyKeyDomain) test.AssertNotError(t, err, "failed to get certificate by name") err = expectOCSP500(cert) test.AssertNotError(t, err, "expected 500 error from OCSP") // Time to revoke! We'll do it by creating a different, successful certificate // with the same key, then revoking that certificate for keyCompromise. revokeClient, err := makeClient() test.AssertNotError(t, err, "creating second acme client") res, err := authAndIssue(nil, certKey, []string{random_domain()}, true) test.AssertNotError(t, err, "issuing second cert") successfulCert := res.certs[0] err = revokeClient.RevokeCertificate( revokeClient.Account, successfulCert, certKey, 1, ) test.AssertNotError(t, err, "revoking second certificate") for i := 0; i < 300; i++ { _, err = ocsp_helper.Req(successfulCert, ocsp_helper.DefaultConfig.WithExpectStatus(ocsp.Revoked).WithExpectReason(ocsp.KeyCompromise)) if err == nil { break } time.Sleep(15 * time.Millisecond) } test.AssertNotError(t, err, "expected status to eventually become revoked") // Try to issue again with the same key, expecting an error because of the key is blocked. _, err = authAndIssue(nil, certKey, []string{"123.example.com"}, true) test.AssertError(t, err, "expected authAndIssue to fail") if !strings.Contains(err.Error(), "public key is forbidden") { t.Errorf("expected issuance to be rejected with a bad pubkey") } }