github.com/letsencrypt/boulder@v0.20251208.0/test/integration/cert_storage_failed_test.go (about)

     1  //go:build integration
     2  
     3  package integration
     4  
     5  import (
     6  	"context"
     7  	"crypto/ecdsa"
     8  	"crypto/elliptic"
     9  	"crypto/rand"
    10  	"crypto/x509"
    11  	"database/sql"
    12  	"fmt"
    13  	"os"
    14  	"os/exec"
    15  	"strings"
    16  	"testing"
    17  
    18  	"github.com/eggsampler/acme/v3"
    19  	_ "github.com/go-sql-driver/mysql"
    20  
    21  	"github.com/letsencrypt/boulder/core"
    22  	"github.com/letsencrypt/boulder/revocation"
    23  	"github.com/letsencrypt/boulder/sa"
    24  	"github.com/letsencrypt/boulder/test"
    25  	"github.com/letsencrypt/boulder/test/vars"
    26  )
    27  
    28  // getPrecertByName finds and parses a precertificate using the given hostname.
    29  // It returns the most recent one.
    30  func getPrecertByName(db *sql.DB, reversedName string) (*x509.Certificate, error) {
    31  	reversedName = sa.EncodeIssuedName(reversedName)
    32  	// Find the certificate from the precertificates table. We don't know the serial so
    33  	// we have to look it up by name.
    34  	var der []byte
    35  	rows, err := db.Query(`
    36  		SELECT der
    37  		FROM issuedNames JOIN precertificates
    38  		USING (serial)
    39  		WHERE reversedName = ?
    40  		ORDER BY issuedNames.id DESC
    41  		LIMIT 1
    42  	`, reversedName)
    43  	for rows.Next() {
    44  		err = rows.Scan(&der)
    45  		if err != nil {
    46  			return nil, err
    47  		}
    48  	}
    49  	if der == nil {
    50  		return nil, fmt.Errorf("no precertificate found for %q", reversedName)
    51  	}
    52  
    53  	cert, err := x509.ParseCertificate(der)
    54  	if err != nil {
    55  		return nil, err
    56  	}
    57  
    58  	return cert, nil
    59  }
    60  
    61  // TestIssuanceCertStorageFailed tests what happens when a storage RPC fails
    62  // during issuance. Specifically, it tests that case where we successfully
    63  // prepared and stored a linting certificate plus metadata, but failed to store
    64  // the corresponding final certificate after issuance completed.
    65  //
    66  // To do this, we need to mess with the database, because we want to cause
    67  // a failure in one specific query, without control ever returning to the
    68  // client. Fortunately we can do this with MySQL triggers.
    69  //
    70  // We also want to make sure we can revoke the precertificate, which we will
    71  // assume exists (note that this different from the root program assumption
    72  // that a final certificate exists for any precertificate, though it is
    73  // similar in spirit).
    74  //
    75  // Note: For this test to support Vitess, it depends on a trigger being
    76  // installed via test/vtcomboserver/install_trigger.sh, since Vitess does not
    77  // support creating triggers via normal SQL commands.
    78  func TestIssuanceCertStorageFailed(t *testing.T) {
    79  	os.Setenv("DIRECTORY", "http://boulder.service.consul:4001/directory")
    80  
    81  	db, err := sql.Open("mysql", vars.DBConnSAIntegrationFullPerms)
    82  	test.AssertNotError(t, err, "failed to open db connection")
    83  
    84  	if os.Getenv("USE_VITESS") == "false" {
    85  		// This block is only necessary for ProxySQL + MariaDB and can be
    86  		// deleted once we're fully migrated to Vitess + MySQL 8, where the
    87  		// trigger is installed via test/vtcomboserver/install_trigger.sh.
    88  
    89  		ctx := context.Background()
    90  		_, err = db.ExecContext(ctx, `DROP TRIGGER IF EXISTS fail_ready`)
    91  		test.AssertNotError(t, err, "failed to drop trigger")
    92  
    93  		// Make a specific insert into certificates fail, for this test but not others.
    94  		// To limit the effect to this one test, we make the trigger aware of a specific
    95  		// hostname used in this test. Since the INSERT to the certificates table
    96  		// doesn't include the hostname, we look it up in the issuedNames table, keyed
    97  		// off of the serial.
    98  		// NOTE: CREATE and DROP TRIGGER do not work in prepared statements. Go's
    99  		// database/sql will automatically try to use a prepared statement if you pass
   100  		// any arguments to Exec besides the query itself, so don't do that.
   101  		_, err = db.ExecContext(ctx, `
   102  		CREATE TRIGGER fail_ready
   103  		BEFORE INSERT ON certificates
   104  		FOR EACH ROW BEGIN
   105  		DECLARE reversedName1 VARCHAR(255);
   106  		SELECT reversedName
   107  		    INTO reversedName1
   108  			FROM issuedNames
   109  			WHERE serial = NEW.serial
   110  			    AND reversedName LIKE "com.wantserror.%";
   111  		IF reversedName1 != "" THEN
   112  			SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Pretend there was an error inserting into certificates';
   113  		END IF;
   114  		END
   115  	`)
   116  		test.AssertNotError(t, err, "failed to create trigger")
   117  
   118  		defer db.ExecContext(ctx, `DROP TRIGGER IF EXISTS fail_ready`)
   119  	}
   120  
   121  	certKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
   122  	test.AssertNotError(t, err, "creating random cert key")
   123  
   124  	// ---- Test revocation by serial ----
   125  	revokeMeDomain := "revokeme.wantserror.com"
   126  	// This should fail because the trigger prevented storing the final certificate.
   127  	_, err = authAndIssue(nil, certKey, []acme.Identifier{{Type: "dns", Value: revokeMeDomain}}, true, "")
   128  	test.AssertError(t, err, "expected authAndIssue to fail")
   129  
   130  	cert, err := getPrecertByName(db, revokeMeDomain)
   131  	test.AssertNotError(t, err, "failed to get certificate by name")
   132  
   133  	// Revoke by invoking admin-revoker
   134  	config := fmt.Sprintf("%s/%s", os.Getenv("BOULDER_CONFIG_DIR"), "admin.json")
   135  	output, err := exec.Command(
   136  		"./bin/admin",
   137  		"-config", config,
   138  		"-dry-run=false",
   139  		"revoke-cert",
   140  		"-serial", core.SerialToString(cert.SerialNumber),
   141  		"-reason", "unspecified",
   142  	).CombinedOutput()
   143  	test.AssertNotError(t, err, fmt.Sprintf("revoking via admin-revoker: %s", string(output)))
   144  
   145  	waitAndCheckRevoked(t, cert, nil, revocation.Unspecified)
   146  
   147  	// ---- Test revocation by key ----
   148  	blockMyKeyDomain := "blockmykey.wantserror.com"
   149  	// This should fail because the trigger prevented storing the final certificate.
   150  	_, err = authAndIssue(nil, certKey, []acme.Identifier{{Type: "dns", Value: blockMyKeyDomain}}, true, "")
   151  	test.AssertError(t, err, "expected authAndIssue to fail")
   152  
   153  	cert, err = getPrecertByName(db, blockMyKeyDomain)
   154  	test.AssertNotError(t, err, "failed to get certificate by name")
   155  
   156  	// Time to revoke! We'll do it by creating a different, successful certificate
   157  	// with the same key, then revoking that certificate for keyCompromise.
   158  	revokeClient, err := makeClient()
   159  	test.AssertNotError(t, err, "creating second acme client")
   160  	res, err := authAndIssue(nil, certKey, []acme.Identifier{{Type: "dns", Value: random_domain()}}, true, "")
   161  	test.AssertNotError(t, err, "issuing second cert")
   162  
   163  	err = revokeClient.RevokeCertificate(
   164  		revokeClient.Account,
   165  		res.certs[0],
   166  		certKey,
   167  		1,
   168  	)
   169  	test.AssertNotError(t, err, "revoking second certificate")
   170  
   171  	waitAndCheckRevoked(t, res.certs[0], res.certs[1], revocation.KeyCompromise)
   172  	waitAndCheckRevoked(t, cert, nil, revocation.KeyCompromise)
   173  
   174  	// Try to issue again with the same key, expecting an error because of the key is blocked.
   175  	_, err = authAndIssue(nil, certKey, []acme.Identifier{{Type: "dns", Value: "123.example.com"}}, true, "")
   176  	test.AssertError(t, err, "expected authAndIssue to fail")
   177  	if !strings.Contains(err.Error(), "public key is forbidden") {
   178  		t.Errorf("expected issuance to be rejected with a bad pubkey")
   179  	}
   180  }