github.com/letsencrypt/boulder@v0.20251208.0/cmd/admin/cert.go (about)

     1  package main
     2  
     3  import (
     4  	"bufio"
     5  	"context"
     6  	"errors"
     7  	"flag"
     8  	"fmt"
     9  	"io"
    10  	"os"
    11  	"os/user"
    12  	"strings"
    13  	"sync"
    14  	"sync/atomic"
    15  	"unicode"
    16  
    17  	core "github.com/letsencrypt/boulder/core"
    18  	berrors "github.com/letsencrypt/boulder/errors"
    19  	rapb "github.com/letsencrypt/boulder/ra/proto"
    20  	"github.com/letsencrypt/boulder/revocation"
    21  	sapb "github.com/letsencrypt/boulder/sa/proto"
    22  )
    23  
    24  // subcommandRevokeCert encapsulates the "admin revoke-cert" command. It accepts
    25  // many flags specifying different ways a to-be-revoked certificate can be
    26  // identified. It then gathers the serial numbers of all identified certs, spins
    27  // up a worker pool, and revokes all of those serials individually.
    28  //
    29  // Note that some batch methods (such as -incident-table and -serials-file) can
    30  // result in high memory usage, as this subcommand will gather every serial in
    31  // memory before beginning to revoke any of them. This trades local memory usage
    32  // for shorter database and gRPC query times, so that we don't need massive
    33  // timeouts when collecting serials to revoke.
    34  type subcommandRevokeCert struct {
    35  	parallelism   uint
    36  	reasonStr     string
    37  	skipBlock     bool
    38  	malformed     bool
    39  	serial        string
    40  	incidentTable string
    41  	serialsFile   string
    42  	privKey       string
    43  	regID         int64
    44  	certFile      string
    45  	crlShard      int64
    46  }
    47  
    48  var _ subcommand = (*subcommandRevokeCert)(nil)
    49  
    50  func (s *subcommandRevokeCert) Desc() string {
    51  	return "Revoke one or more certificates"
    52  }
    53  
    54  func (s *subcommandRevokeCert) Flags(flag *flag.FlagSet) {
    55  	// General flags relevant to all certificate input methods.
    56  	flag.UintVar(&s.parallelism, "parallelism", 10, "Number of concurrent workers to use while revoking certs")
    57  	flag.StringVar(&s.reasonStr, "reason", "unspecified", "Revocation reason (unspecified, keyCompromise, superseded, cessationOfOperation, or privilegeWithdrawn)")
    58  	flag.BoolVar(&s.skipBlock, "skip-block-key", false, "Skip blocking the key, if revoked for keyCompromise - use with extreme caution")
    59  	flag.BoolVar(&s.malformed, "malformed", false, "Indicates that the cert cannot be parsed - use with caution")
    60  	flag.Int64Var(&s.crlShard, "crl-shard", 0, "For malformed certs, the CRL shard the certificate belongs to")
    61  
    62  	// Flags specifying the input method for the certificates to be revoked.
    63  	flag.StringVar(&s.serial, "serial", "", "Revoke the certificate with this hex serial")
    64  	flag.StringVar(&s.incidentTable, "incident-table", "", "Revoke all certificates whose serials are in this table")
    65  	flag.StringVar(&s.serialsFile, "serials-file", "", "Revoke all certificates whose hex serials are in this file")
    66  	flag.StringVar(&s.privKey, "private-key", "", "Revoke all certificates whose pubkey matches this private key")
    67  	flag.Int64Var(&s.regID, "reg-id", 0, "Revoke all certificates issued to this account")
    68  	flag.StringVar(&s.certFile, "cert-file", "", "Revoke the single PEM-formatted certificate in this file")
    69  }
    70  
    71  func (s *subcommandRevokeCert) Run(ctx context.Context, a *admin) error {
    72  	if s.parallelism == 0 {
    73  		// Why did they override it to 0, instead of just leaving it the default?
    74  		return fmt.Errorf("got unacceptable parallelism %d", s.parallelism)
    75  	}
    76  
    77  	reasonCode, err := revocation.StringToReason(s.reasonStr)
    78  	if err != nil {
    79  		return fmt.Errorf("looking up revocation reason: %w", err)
    80  	}
    81  
    82  	if s.skipBlock && reasonCode == revocation.KeyCompromise {
    83  		// We would only add the SPKI hash of the pubkey to the blockedKeys table if
    84  		// the revocation reason is keyCompromise.
    85  		return errors.New("-skip-block-key only makes sense with -reason=1")
    86  	}
    87  
    88  	if s.malformed && reasonCode == revocation.KeyCompromise {
    89  		// This is because we can't extract and block the pubkey if we can't
    90  		// parse the certificate.
    91  		return errors.New("cannot revoke malformed certs for reason keyCompromise")
    92  	}
    93  
    94  	// This is a map of all input-selection flags to whether or not they were set
    95  	// to a non-default value. We use this to ensure that exactly one input
    96  	// selection flag was given on the command line.
    97  	setInputs := map[string]bool{
    98  		"-serial":         s.serial != "",
    99  		"-incident-table": s.incidentTable != "",
   100  		"-serials-file":   s.serialsFile != "",
   101  		"-private-key":    s.privKey != "",
   102  		"-reg-id":         s.regID != 0,
   103  		"-cert-file":      s.certFile != "",
   104  	}
   105  	activeFlag, err := findActiveInputMethodFlag(setInputs)
   106  	if err != nil {
   107  		return err
   108  	}
   109  
   110  	var serials []string
   111  	switch activeFlag {
   112  	case "-serial":
   113  		serials, err = []string{s.serial}, nil
   114  	case "-incident-table":
   115  		serials, err = a.serialsFromIncidentTable(ctx, s.incidentTable)
   116  	case "-serials-file":
   117  		serials, err = a.serialsFromFile(ctx, s.serialsFile)
   118  	case "-private-key":
   119  		serials, err = a.serialsFromPrivateKey(ctx, s.privKey)
   120  	case "-reg-id":
   121  		serials, err = a.serialsFromRegID(ctx, s.regID)
   122  	case "-cert-file":
   123  		serials, err = a.serialsFromCertPEM(ctx, s.certFile)
   124  	default:
   125  		return errors.New("no recognized input method flag set (this shouldn't happen)")
   126  	}
   127  	if err != nil {
   128  		return fmt.Errorf("collecting serials to revoke: %w", err)
   129  	}
   130  
   131  	serials, err = cleanSerials(serials)
   132  	if err != nil {
   133  		return err
   134  	}
   135  
   136  	if len(serials) == 0 {
   137  		return errors.New("no serials to revoke found")
   138  	}
   139  
   140  	a.log.Infof("Found %d certificates to revoke", len(serials))
   141  
   142  	if s.malformed {
   143  		return s.revokeMalformed(ctx, a, serials, reasonCode)
   144  	}
   145  
   146  	err = a.revokeSerials(ctx, serials, reasonCode, s.skipBlock, s.parallelism)
   147  	if err != nil {
   148  		return fmt.Errorf("revoking serials: %w", err)
   149  	}
   150  
   151  	return nil
   152  }
   153  
   154  func (s *subcommandRevokeCert) revokeMalformed(ctx context.Context, a *admin, serials []string, reasonCode revocation.Reason) error {
   155  	u, err := user.Current()
   156  	if err != nil {
   157  		return fmt.Errorf("getting admin username: %w", err)
   158  	}
   159  	if s.crlShard == 0 {
   160  		return errors.New("when revoking malformed certificates, a nonzero CRL shard must be specified")
   161  	}
   162  	if len(serials) > 1 {
   163  		return errors.New("when revoking malformed certificates, only one cert at a time is allowed")
   164  	}
   165  	_, err = a.rac.AdministrativelyRevokeCertificate(
   166  		ctx,
   167  		&rapb.AdministrativelyRevokeCertificateRequest{
   168  			Serial:       serials[0],
   169  			Code:         int64(reasonCode),
   170  			AdminName:    u.Username,
   171  			SkipBlockKey: s.skipBlock,
   172  			Malformed:    true,
   173  			CrlShard:     s.crlShard,
   174  		},
   175  	)
   176  	return err
   177  }
   178  
   179  func (a *admin) serialsFromIncidentTable(ctx context.Context, tableName string) ([]string, error) {
   180  	stream, err := a.saroc.SerialsForIncident(ctx, &sapb.SerialsForIncidentRequest{IncidentTable: tableName})
   181  	if err != nil {
   182  		return nil, fmt.Errorf("setting up stream of serials from incident table %q: %s", tableName, err)
   183  	}
   184  
   185  	var serials []string
   186  	for {
   187  		is, err := stream.Recv()
   188  		if err != nil {
   189  			if err == io.EOF {
   190  				break
   191  			}
   192  			return nil, fmt.Errorf("streaming serials from incident table %q: %s", tableName, err)
   193  		}
   194  		serials = append(serials, is.Serial)
   195  	}
   196  
   197  	return serials, nil
   198  }
   199  
   200  func (a *admin) serialsFromFile(_ context.Context, filePath string) ([]string, error) {
   201  	file, err := os.Open(filePath)
   202  	if err != nil {
   203  		return nil, fmt.Errorf("opening serials file: %w", err)
   204  	}
   205  
   206  	var serials []string
   207  	scanner := bufio.NewScanner(file)
   208  	for scanner.Scan() {
   209  		serial := scanner.Text()
   210  		if serial == "" {
   211  			continue
   212  		}
   213  		serials = append(serials, serial)
   214  	}
   215  
   216  	return serials, nil
   217  }
   218  
   219  func (a *admin) serialsFromPrivateKey(ctx context.Context, privkeyFile string) ([]string, error) {
   220  	spkiHash, err := a.spkiHashFromPrivateKey(privkeyFile)
   221  	if err != nil {
   222  		return nil, err
   223  	}
   224  
   225  	stream, err := a.saroc.GetSerialsByKey(ctx, &sapb.SPKIHash{KeyHash: spkiHash})
   226  	if err != nil {
   227  		return nil, fmt.Errorf("setting up stream of serials from SA: %s", err)
   228  	}
   229  
   230  	var serials []string
   231  	for {
   232  		serial, err := stream.Recv()
   233  		if err != nil {
   234  			if err == io.EOF {
   235  				break
   236  			}
   237  			return nil, fmt.Errorf("streaming serials from SA: %s", err)
   238  		}
   239  		serials = append(serials, serial.Serial)
   240  	}
   241  
   242  	return serials, nil
   243  }
   244  
   245  func (a *admin) serialsFromRegID(ctx context.Context, regID int64) ([]string, error) {
   246  	_, err := a.saroc.GetRegistration(ctx, &sapb.RegistrationID{Id: regID})
   247  	if err != nil {
   248  		return nil, fmt.Errorf("couldn't confirm regID exists: %w", err)
   249  	}
   250  
   251  	stream, err := a.saroc.GetSerialsByAccount(ctx, &sapb.RegistrationID{Id: regID})
   252  	if err != nil {
   253  		return nil, fmt.Errorf("setting up stream of serials from SA: %s", err)
   254  	}
   255  
   256  	var serials []string
   257  	for {
   258  		serial, err := stream.Recv()
   259  		if err != nil {
   260  			if err == io.EOF {
   261  				break
   262  			}
   263  			return nil, fmt.Errorf("streaming serials from SA: %s", err)
   264  		}
   265  		serials = append(serials, serial.Serial)
   266  	}
   267  
   268  	return serials, nil
   269  }
   270  
   271  func (a *admin) serialsFromCertPEM(_ context.Context, filename string) ([]string, error) {
   272  	cert, err := core.LoadCert(filename)
   273  	if err != nil {
   274  		return nil, fmt.Errorf("loading certificate pem: %w", err)
   275  	}
   276  
   277  	return []string{core.SerialToString(cert.SerialNumber)}, nil
   278  }
   279  
   280  // cleanSerials removes non-alphanumeric characters from the serials and checks
   281  // that all resulting serials are valid (hex encoded, and the correct length).
   282  func cleanSerials(serials []string) ([]string, error) {
   283  	serialStrip := func(r rune) rune {
   284  		switch {
   285  		case unicode.IsLetter(r):
   286  			return r
   287  		case unicode.IsDigit(r):
   288  			return r
   289  		}
   290  		return rune(-1)
   291  	}
   292  
   293  	var ret []string
   294  	for _, s := range serials {
   295  		cleaned := strings.Map(serialStrip, s)
   296  		if !core.ValidSerial(cleaned) {
   297  			return nil, fmt.Errorf("cleaned serial %q is not valid", cleaned)
   298  		}
   299  		ret = append(ret, cleaned)
   300  	}
   301  	return ret, nil
   302  }
   303  
   304  func (a *admin) revokeSerials(ctx context.Context, serials []string, reason revocation.Reason, skipBlockKey bool, parallelism uint) error {
   305  	u, err := user.Current()
   306  	if err != nil {
   307  		return fmt.Errorf("getting admin username: %w", err)
   308  	}
   309  
   310  	var errCount atomic.Uint64
   311  	wg := new(sync.WaitGroup)
   312  	work := make(chan string, parallelism)
   313  	for range parallelism {
   314  		wg.Go(func() {
   315  			for serial := range work {
   316  				_, err := a.rac.AdministrativelyRevokeCertificate(
   317  					ctx,
   318  					&rapb.AdministrativelyRevokeCertificateRequest{
   319  						Serial:       serial,
   320  						Code:         int64(reason),
   321  						AdminName:    u.Username,
   322  						SkipBlockKey: skipBlockKey,
   323  						// This is a well-formed certificate so send CrlShard 0
   324  						// to let the RA figure out the right shard from the cert.
   325  						Malformed: false,
   326  						CrlShard:  0,
   327  					},
   328  				)
   329  				if err != nil {
   330  					errCount.Add(1)
   331  					if errors.Is(err, berrors.AlreadyRevoked) {
   332  						a.log.Errf("not revoking %q: already revoked", serial)
   333  					} else {
   334  						a.log.Errf("failed to revoke %q: %s", serial, err)
   335  					}
   336  				}
   337  			}
   338  		})
   339  	}
   340  
   341  	for _, serial := range serials {
   342  		work <- serial
   343  	}
   344  	close(work)
   345  	wg.Wait()
   346  
   347  	if errCount.Load() > 0 {
   348  		return fmt.Errorf("encountered %d errors while revoking certs; see logs above for details", errCount.Load())
   349  	}
   350  
   351  	return nil
   352  }