boulder/cmd/admin-revoker/main.go

632 lines
20 KiB
Go

package notmain
import (
"bufio"
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"errors"
"flag"
"fmt"
"io"
"os"
"os/user"
"sort"
"strconv"
"sync"
"github.com/jmhodges/clock"
"github.com/letsencrypt/boulder/cmd"
"github.com/letsencrypt/boulder/core"
"github.com/letsencrypt/boulder/db"
berrors "github.com/letsencrypt/boulder/errors"
"github.com/letsencrypt/boulder/features"
bgrpc "github.com/letsencrypt/boulder/grpc"
blog "github.com/letsencrypt/boulder/log"
"github.com/letsencrypt/boulder/metrics"
"github.com/letsencrypt/boulder/privatekey"
rapb "github.com/letsencrypt/boulder/ra/proto"
"github.com/letsencrypt/boulder/revocation"
"github.com/letsencrypt/boulder/sa"
sapb "github.com/letsencrypt/boulder/sa/proto"
)
const usageString = `
usage:
list-reasons -config <path>
serial-revoke -config <path> <serial> <reason-code>
batched-serial-revoke -config <path> <serial-file-path> <reason-code> <parallelism>
incident-table-revoke -config <path> <table-name> <reason-code> <parallelism>
reg-revoke -config <path> <registration-id> <reason-code>
private-key-block -config <path> -comment="<string>" -dry-run=<bool> <priv-key-path>
private-key-revoke -config <path> -comment="<string>" -dry-run=<bool> <priv-key-path>
descriptions:
list-reasons List all revocation reason codes.
serial-revoke Revoke a single certificate by the hex serial number.
malformed-revoke Revoke a single certificate by the hex serial number. Works even
if the certificate cannot be parsed from the database.
Note: This does not purge the Akamai cache.
Note: This cannot be used to revoke for key compromise.
batched-serial-revoke Revoke all certificates contained in a file of hex serial numbers.
incident-table-revoke Revoke all certificates in the provided incident table.
reg-revoke Revoke all certificates associated with a registration ID.
private-key-block Adds the SPKI hash, derived from the provided private key, to the
blocked keys table. <priv-key-path> is expected to be the path
to a PEM formatted file containing an RSA or ECDSA private key.
private-key-revoke Revoke all certificates matching the SPKI hash derived from the
provided private key. Then adds the hash to the blocked keys
table. <priv-key-path> is expected to be the path to a PEM
formatted file containing an RSA or ECDSA private key.
flags:
all:
-config File path to the configuration file for this service (required)
private-key-block | private-key-revoke:
-dry-run true (default): only queries for affected certificates. false: will
perform the requested block or revoke action. Only implemented for
private-key-block and private-key-revoke.
-comment Comment to include in the blocked keys table entry. (default: "")
`
type Config struct {
Revoker struct {
DB cmd.DBConfig
// Similarly, the Revoker needs a TLSConfig to set up its GRPC client
// certs, but doesn't get the TLS field from ServiceConfig, so declares
// its own.
TLS cmd.TLSConfig
RAService *cmd.GRPCClientConfig
SAService *cmd.GRPCClientConfig
Features map[string]bool
}
Syslog cmd.SyslogConfig
}
type revoker struct {
rac rapb.RegistrationAuthorityClient
sac sapb.StorageAuthorityClient
dbMap *db.WrappedMap
clk clock.Clock
log blog.Logger
}
func newRevoker(c Config) *revoker {
logger := cmd.NewLogger(c.Syslog)
// TODO(#6840) Rework admin-revoker to export prometheus metrics.
tlsConfig, err := c.Revoker.TLS.Load(metrics.NoopRegisterer)
cmd.FailOnError(err, "TLS config")
clk := cmd.Clock()
raConn, err := bgrpc.ClientSetup(c.Revoker.RAService, tlsConfig, metrics.NoopRegisterer, clk)
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to RA")
rac := rapb.NewRegistrationAuthorityClient(raConn)
dbMap, err := sa.InitWrappedDb(c.Revoker.DB, nil, logger)
cmd.FailOnError(err, "While initializing dbMap")
saConn, err := bgrpc.ClientSetup(c.Revoker.SAService, tlsConfig, metrics.NoopRegisterer, clk)
cmd.FailOnError(err, "Failed to load credentials and create gRPC connection to SA")
sac := sapb.NewStorageAuthorityClient(saConn)
return &revoker{
rac: rac,
sac: sac,
dbMap: dbMap,
clk: clk,
log: logger,
}
}
func (r *revoker) revokeCertificate(ctx context.Context, certObj core.Certificate, reasonCode revocation.Reason, skipBlockKey bool) error {
if reasonCode < 0 || reasonCode == 7 || reasonCode > 10 {
panic(fmt.Sprintf("Invalid reason code: %d", reasonCode))
}
u, err := user.Current()
if err != nil {
return err
}
var req *rapb.AdministrativelyRevokeCertificateRequest
if certObj.DER != nil {
cert, err := x509.ParseCertificate(certObj.DER)
if err != nil {
return err
}
req = &rapb.AdministrativelyRevokeCertificateRequest{
Cert: cert.Raw,
Serial: core.SerialToString(cert.SerialNumber),
Code: int64(reasonCode),
AdminName: u.Username,
SkipBlockKey: skipBlockKey,
}
} else {
req = &rapb.AdministrativelyRevokeCertificateRequest{
Serial: certObj.Serial,
Code: int64(reasonCode),
AdminName: u.Username,
SkipBlockKey: skipBlockKey,
}
}
_, err = r.rac.AdministrativelyRevokeCertificate(ctx, req)
if err != nil {
return err
}
r.log.Infof("Revoked certificate %s with reason '%s'", certObj.Serial, revocation.ReasonToString[reasonCode])
return nil
}
func (r *revoker) revokeBySerial(ctx context.Context, serial string, reasonCode revocation.Reason, skipBlockKey bool) error {
certObj, err := sa.SelectPrecertificate(r.dbMap, serial)
if err != nil {
if db.IsNoRows(err) {
return berrors.NotFoundError("precertificate with serial %q not found", serial)
}
return err
}
return r.revokeCertificate(ctx, certObj, reasonCode, skipBlockKey)
}
func (r *revoker) revokeSerialBatchFile(ctx context.Context, serialPath string, reasonCode revocation.Reason, parallelism int) error {
file, err := os.Open(serialPath)
if err != nil {
return err
}
scanner := bufio.NewScanner(file)
if err != nil {
return err
}
wg := new(sync.WaitGroup)
work := make(chan string, parallelism)
for i := 0; i < parallelism; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for serial := range work {
// handle newlines gracefully
if serial == "" {
continue
}
err := r.revokeBySerial(ctx, serial, reasonCode, false)
if err != nil {
r.log.Errf("failed to revoke %q: %s", serial, err)
}
}
}()
}
for scanner.Scan() {
serial := scanner.Text()
if serial == "" {
continue
}
work <- serial
}
close(work)
wg.Wait()
return nil
}
func (r *revoker) revokeIncidentTableSerials(ctx context.Context, tableName string, reasonCode revocation.Reason, parallelism int) error {
wg := new(sync.WaitGroup)
work := make(chan string, parallelism)
for i := 0; i < parallelism; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for serial := range work {
err := r.revokeBySerial(ctx, serial, reasonCode, false)
if err != nil {
r.log.Errf("failed to revoke %q: %s", serial, err)
}
}
}()
}
stream, err := r.sac.SerialsForIncident(ctx, &sapb.SerialsForIncidentRequest{IncidentTable: tableName})
if err != nil {
return fmt.Errorf("setting up stream of serials from incident table %q: %s", tableName, err)
}
var atLeastOne bool
for {
is, err := stream.Recv()
if err != nil {
if err == io.EOF {
break
}
return fmt.Errorf("streaming serials from incident table %q: %s", tableName, err)
}
atLeastOne = true
work <- is.Serial
}
if !atLeastOne {
r.log.AuditInfof("No serials found in incident table %q", tableName)
}
close(work)
wg.Wait()
return nil
}
func (r *revoker) revokeByReg(ctx context.Context, regID int64, reasonCode revocation.Reason) error {
_, err := r.sac.GetRegistration(ctx, &sapb.RegistrationID{Id: regID})
if err != nil {
return fmt.Errorf("couldn't fetch registration: %w", err)
}
certObjs, err := sa.SelectPrecertificates(r.dbMap, "WHERE registrationID = :regID", map[string]interface{}{"regID": regID})
if err != nil {
return err
}
for _, certObj := range certObjs {
err = r.revokeCertificate(ctx, certObj.Certificate, reasonCode, false)
if err != nil {
return err
}
}
return nil
}
func (r *revoker) revokeMalformedBySerial(ctx context.Context, serial string, reasonCode revocation.Reason) error {
return r.revokeCertificate(ctx, core.Certificate{Serial: serial}, reasonCode, false)
}
// blockByPrivateKey blocks future issuance for certificates with a a public key
// matching the SubjectPublicKeyInfo hash generated from the PublicKey embedded
// in privateKey. The embedded PublicKey will be verified as an actual match for
// the provided private key before any blocking takes place. This method does
// not revoke any certificates directly. However, 'bad-key-revoker', which
// references the 'blockedKeys' table, will eventually revoke certificates with
// a matching SPKI hash.
func (r *revoker) blockByPrivateKey(ctx context.Context, comment string, privateKey string) error {
_, publicKey, err := privatekey.Load(privateKey)
if err != nil {
return err
}
spkiHash, err := getPublicKeySPKIHash(publicKey)
if err != nil {
return err
}
u, err := user.Current()
if err != nil {
return err
}
dbcomment := fmt.Sprintf("%s: %s", u.Username, comment)
req := &sapb.AddBlockedKeyRequest{
KeyHash: spkiHash,
Added: r.clk.Now().UnixNano(),
Source: "admin-revoker",
Comment: dbcomment,
RevokedBy: 0,
}
_, err = r.sac.AddBlockedKey(ctx, req)
if err != nil {
return err
}
return nil
}
// revokeByPrivateKey revokes all certificates with a public key matching the
// SubjectPublicKeyInfo hash generated from the PublicKey embedded in
// privateKey. The embedded PublicKey will be verified as an actual match for the
// provided private key before any revocation takes place. The provided key will
// not be added to the 'blockedKeys' table. This is done to avoid a race between
// 'admin-revoker' and 'bad-key-revoker'. You MUST call blockByPrivateKey after
// calling this function, on pain of violating the BRs.
func (r *revoker) revokeByPrivateKey(ctx context.Context, privateKey string) error {
_, publicKey, err := privatekey.Load(privateKey)
if err != nil {
return err
}
spkiHash, err := getPublicKeySPKIHash(publicKey)
if err != nil {
return err
}
matches, err := r.getCertsMatchingSPKIHash(spkiHash)
if err != nil {
return err
}
for i, match := range matches {
resp, err := r.sac.GetCertificateStatus(ctx, &sapb.Serial{Serial: match})
if err != nil {
return fmt.Errorf(
"failed to get status for serial %q. Entry %d of %d affected certificates: %w",
match,
(i + 1),
len(matches),
err,
)
}
if resp.Status != string(core.OCSPStatusGood) {
r.log.AuditInfof("serial %q is already revoked, skipping", match)
continue
}
err = r.revokeBySerial(ctx, match, revocation.Reason(1), true)
if err != nil {
return fmt.Errorf(
"failed to revoke serial %q. Entry %d of %d affected certificates: %w",
match,
(i + 1),
len(matches),
err,
)
}
}
return nil
}
func (r *revoker) spkiHashInBlockedKeys(spkiHash []byte) (bool, error) {
var count int
err := r.dbMap.SelectOne(&count, "SELECT COUNT(*) as count FROM blockedKeys WHERE keyHash = ?", spkiHash)
if err != nil {
return false, err
}
if count > 0 {
return true, nil
}
return false, nil
}
func (r *revoker) countCertsMatchingSPKIHash(spkiHash []byte) (int, error) {
var count int
err := r.dbMap.SelectOne(&count, "SELECT COUNT(*) as count FROM keyHashToSerial WHERE keyHash = ?", spkiHash)
if err != nil {
return 0, err
}
return count, nil
}
// TODO(#5899) Use an non-wrapped sql.Db client to iterate over results and
// return them on a channel.
func (r *revoker) getCertsMatchingSPKIHash(spkiHash []byte) ([]string, error) {
var h []string
_, err := r.dbMap.Select(&h, "SELECT certSerial FROM keyHashToSerial WHERE keyHash = ?", spkiHash)
if err != nil {
if db.IsNoRows(err) {
return nil, berrors.NotFoundError("no certificates with a matching SPKI hash were found")
}
return nil, err
}
return h, nil
}
// This abstraction is needed so that we can use sort.Sort below
type revocationCodes []revocation.Reason
func (rc revocationCodes) Len() int { return len(rc) }
func (rc revocationCodes) Less(i, j int) bool { return rc[i] < rc[j] }
func (rc revocationCodes) Swap(i, j int) { rc[i], rc[j] = rc[j], rc[i] }
func privateKeyBlock(r *revoker, dryRun bool, comment string, count int, spkiHash []byte, keyPath string) error {
keyExists, err := r.spkiHashInBlockedKeys(spkiHash)
if err != nil {
return fmt.Errorf("while checking if the provided key already exists in the 'blockedKeys' table: %s", err)
}
if keyExists {
return errors.New("the provided key already exists in the 'blockedKeys' table")
}
if dryRun {
r.log.AuditInfof(
"To block issuance for this key and revoke %d certificates via bad-key-revoker, run with -dry-run=false",
count,
)
r.log.AuditInfo("No keys were blocked or certificates revoked, exiting...")
return nil
}
r.log.AuditInfo("Attempting to block issuance for the provided key")
err = r.blockByPrivateKey(context.Background(), comment, keyPath)
if err != nil {
return fmt.Errorf("while attempting to block issuance for the provided key: %s", err)
}
r.log.AuditInfo("Issuance for the provided key has been successfully blocked, exiting...")
return nil
}
func privateKeyRevoke(r *revoker, dryRun bool, comment string, count int, keyPath string) error {
if dryRun {
r.log.AuditInfof(
"To immediately revoke %d certificates and block issuance for this key, run with -dry-run=false",
count,
)
r.log.AuditInfo("No keys were blocked or certificates revoked, exiting...")
return nil
}
if count <= 0 {
// Do not revoke.
return nil
}
// Revoke certificates.
r.log.AuditInfof("Attempting to revoke %d certificates", count)
err := r.revokeByPrivateKey(context.Background(), keyPath)
if err != nil {
return fmt.Errorf("while attempting to revoke certificates for the provided key: %s", err)
}
r.log.AuditInfo("All certificates matching using the provided key have been successfully")
// Block future issuance.
r.log.AuditInfo("Attempting to block issuance for the provided key")
err = r.blockByPrivateKey(context.Background(), comment, keyPath)
if err != nil {
return fmt.Errorf("while attempting to block issuance for the provided key: %s", err)
}
r.log.AuditInfo("All certificates have been successfully revoked and issuance blocked, exiting...")
return nil
}
// getPublicKeySPKIHash returns a hash of the SubjectPublicKeyInfo for the
// provided public key.
func getPublicKeySPKIHash(pubKey crypto.PublicKey) ([]byte, error) {
rawSubjectPublicKeyInfo, err := x509.MarshalPKIXPublicKey(pubKey)
if err != nil {
return nil, err
}
spkiHash := sha256.Sum256(rawSubjectPublicKeyInfo)
return spkiHash[:], nil
}
func main() {
usage := func() {
fmt.Fprint(os.Stderr, usageString)
os.Exit(1)
}
if len(os.Args) <= 2 {
usage()
}
command := os.Args[1]
flagSet := flag.NewFlagSet(command, flag.ContinueOnError)
configFile := flagSet.String("config", "", "File path to the configuration file for this service")
dryRun := flagSet.Bool(
"dry-run",
true,
"true (default): only queries for affected certificates. false: will perform the requested block or revoke action",
)
comment := flagSet.String("comment", "", "Comment to include in the blocked key database entry ")
err := flagSet.Parse(os.Args[2:])
cmd.FailOnError(err, "Error parsing flagset")
if *configFile == "" {
usage()
}
var c Config
err = cmd.ReadConfigFile(*configFile, &c)
cmd.FailOnError(err, "Reading JSON config file into config structure")
err = features.Set(c.Revoker.Features)
cmd.FailOnError(err, "Failed to set feature flags")
ctx := context.Background()
r := newRevoker(c)
defer r.log.AuditPanic()
args := flagSet.Args()
switch {
case command == "serial-revoke" && len(args) == 2:
// 1: serial, 2: reasonCode
serial := args[0]
reasonCode, err := strconv.Atoi(args[1])
cmd.FailOnError(err, "Reason code argument must be an integer")
err = r.revokeBySerial(ctx, serial, revocation.Reason(reasonCode), false)
cmd.FailOnError(err, "Couldn't revoke certificate by serial")
case command == "batched-serial-revoke" && len(args) == 3:
// 1: serial file path, 2: reasonCode, 3: parallelism
serialPath := args[0]
reasonCode, err := strconv.Atoi(args[1])
cmd.FailOnError(err, "Reason code argument must be an integer")
parallelism, err := strconv.Atoi(args[2])
cmd.FailOnError(err, "parallelism argument must be an integer")
if parallelism < 1 {
cmd.Fail("parallelism argument must be >= 1")
}
err = r.revokeSerialBatchFile(ctx, serialPath, revocation.Reason(reasonCode), parallelism)
cmd.FailOnError(err, "Batch revocation failed")
case command == "reg-revoke" && len(args) == 2:
// 1: registration ID, 2: reasonCode
regID, err := strconv.ParseInt(args[0], 10, 64)
cmd.FailOnError(err, "Registration ID argument must be an integer")
reasonCode, err := strconv.Atoi(args[1])
cmd.FailOnError(err, "Reason code argument must be an integer")
err = r.revokeByReg(ctx, regID, revocation.Reason(reasonCode))
cmd.FailOnError(err, "Couldn't revoke certificate by registration")
case command == "malformed-revoke" && len(args) == 3:
// 1: serial, 2: reasonCode
serial := args[0]
reasonCode, err := strconv.Atoi(args[1])
cmd.FailOnError(err, "Reason code argument must be an integer")
err = r.revokeMalformedBySerial(ctx, serial, revocation.Reason(reasonCode))
cmd.FailOnError(err, "Couldn't revoke certificate by serial")
case command == "list-reasons":
var codes revocationCodes
for k := range revocation.ReasonToString {
codes = append(codes, k)
}
sort.Sort(codes)
fmt.Printf("Revocation reason codes\n-----------------------\n\n")
for _, k := range codes {
fmt.Printf("%d: %s\n", k, revocation.ReasonToString[k])
}
case (command == "private-key-block" || command == "private-key-revoke") && len(args) == 1:
// 1: keyPath
keyPath := args[0]
_, publicKey, err := privatekey.Load(keyPath)
cmd.FailOnError(err, "Failed to load the provided private key")
r.log.AuditInfo("The provided private key has been successfully verified")
spkiHash, err := getPublicKeySPKIHash(publicKey)
cmd.FailOnError(err, "While obtaining the SPKI hash for the provided key")
count, err := r.countCertsMatchingSPKIHash(spkiHash)
cmd.FailOnError(err, "While retrieving a count of certificates matching the provided key")
r.log.AuditInfof("Found %d certificates matching the provided key", count)
if command == "private-key-block" {
err := privateKeyBlock(r, *dryRun, *comment, count, spkiHash, keyPath)
cmd.FailOnError(err, "")
}
if command == "private-key-revoke" {
err := privateKeyRevoke(r, *dryRun, *comment, count, keyPath)
cmd.FailOnError(err, "")
}
case command == "incident-table-revoke" && len(args) == 3:
// 1: tableName, 2: reasonCode, 3: parallelism
tableName := args[0]
reasonCode, err := strconv.Atoi(args[1])
cmd.FailOnError(err, "Reason code argument must be an integer")
parallelism, err := strconv.Atoi(args[2])
cmd.FailOnError(err, "parallelism argument must be an integer")
if parallelism < 1 {
cmd.Fail("parallelism argument must be >= 1")
}
err = r.revokeIncidentTableSerials(ctx, tableName, revocation.Reason(reasonCode), parallelism)
cmd.FailOnError(err, "Couldn't revoke serials in incident table")
default:
usage()
}
}
func init() {
cmd.RegisterCommand("admin-revoker", main, &cmd.ConfigValidator{Config: &Config{}})
}