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 }