Skip to content

Commit

Permalink
SA/ARI: Add method of tracking certificate replacement (letsencrypt#7284
Browse files Browse the repository at this point in the history
  • Loading branch information
beautifulentropy authored Feb 8, 2024
1 parent 8d7e84b commit f10abd2
Show file tree
Hide file tree
Showing 14 changed files with 1,069 additions and 560 deletions.
10 changes: 10 additions & 0 deletions features/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ type Config struct {
// number of failures is greater than the configured
// maxRemoteValidationFailures. Only used when EnforceMultiCAA is true.
MultiCAAFullResults bool

// TrackReplacementCertificatesARI, when enabled, triggers the following
// behavior:
// - SA.NewOrderAndAuthzs: upon receiving a NewOrderRequest with a
// 'replacesSerial' value, will create a new entry in the 'replacement
// Orders' table. This will occur inside of the new order transaction.
// - SA.FinalizeOrder will update the 'replaced' column of any row with
// a 'orderID' matching the finalized order to true. This will occur
// inside of the finalize (order) transaction.
TrackReplacementCertificatesARI bool
}

var fMu = new(sync.RWMutex)
Expand Down
5 changes: 5 additions & 0 deletions mocks/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -612,6 +612,11 @@ func (sa *StorageAuthority) UpdateCRLShard(ctx context.Context, req *sapb.Update
return nil, errors.New("unimplemented")
}

// ReplacementOrderExists is a mock.
func (sa *StorageAuthorityReadOnly) ReplacementOrderExists(ctx context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.Exists, error) {
return nil, nil
}

// PublisherClient is a mock
type PublisherClient struct {
// empty
Expand Down
1 change: 1 addition & 0 deletions sa/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ func initTables(dbMap *borp.DbMap) {
dbMap.AddTable(incidentSerialModel{})
dbMap.AddTableWithName(crlShardModel{}, "crlShards").SetKeys(true, "ID")
dbMap.AddTableWithName(revokedCertModel{}, "revokedCertificates").SetKeys(true, "ID")
dbMap.AddTableWithName(replacementOrderModel{}, "replacementOrders").SetKeys(true, "ID")

// Read-only maps used for selecting subsets of columns.
dbMap.AddTableWithName(CertStatusMetadata{}, "certificateStatus")
Expand Down
20 changes: 20 additions & 0 deletions sa/db-next/boulder_sa/20240119000000_ReplacementOrders.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied

CREATE TABLE `replacementOrders` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`serial` varchar(255) NOT NULL,
`orderID` bigint(20) NOT NULL,
`orderExpires` datetime NOT NULL,
`replaced` boolean DEFAULT false,
PRIMARY KEY (`id`),
KEY `serial_idx` (`serial`),
KEY `orderID_idx` (`orderID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY RANGE(id)
(PARTITION p_start VALUES LESS THAN (MAXVALUE));

-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back

DROP TABLE `replacementOrders`;
2 changes: 2 additions & 0 deletions sa/db-users/boulder_sa.sql
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ GRANT SELECT,INSERT,UPDATE ON newOrdersRL TO 'sa'@'localhost';
GRANT SELECT ON incidents TO 'sa'@'localhost';
GRANT SELECT,INSERT,UPDATE ON crlShards TO 'sa'@'localhost';
GRANT SELECT,INSERT,UPDATE ON revokedCertificates TO 'sa'@'localhost';
GRANT SELECT,INSERT,UPDATE ON replacementOrders TO 'sa'@'localhost';

GRANT SELECT ON certificates TO 'sa_ro'@'localhost';
GRANT SELECT ON certificateStatus TO 'sa_ro'@'localhost';
Expand All @@ -54,6 +55,7 @@ GRANT SELECT ON newOrdersRL TO 'sa_ro'@'localhost';
GRANT SELECT ON incidents TO 'sa_ro'@'localhost';
GRANT SELECT ON crlShards TO 'sa_ro'@'localhost';
GRANT SELECT ON revokedCertificates TO 'sa_ro'@'localhost';
GRANT SELECT ON replacementOrders TO 'sa_ro'@'localhost';

-- OCSP Responder
GRANT SELECT ON certificateStatus TO 'ocsp_resp'@'localhost';
Expand Down
81 changes: 81 additions & 0 deletions sa/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
Expand Down Expand Up @@ -1188,3 +1189,83 @@ type revokedCertModel struct {
RevokedDate time.Time `db:"revokedDate"`
RevokedReason revocation.Reason `db:"revokedReason"`
}

// replacementOrderModel represents one row in the replacementOrders table. It
// contains all of the information necessary to link a renewal order to the
// certificate it replaces.
type replacementOrderModel struct {
// ID is an auto-incrementing row ID.
ID int64 `db:"id"`
// Serial is the serial number of the replaced certificate.
Serial string `db:"serial"`
// OrderId is the ID of the replacement order
OrderID int64 `db:"orderID"`
// OrderExpiry is the expiry time of the new order. This is used to
// determine if we can accept a new replacement order for the same Serial.
OrderExpires time.Time `db:"orderExpires"`
// Replaced is a boolean indicating whether the certificate has been
// replaced, i.e. whether the new order has been finalized. Once this is
// true, no new replacement orders can be accepted for the same Serial.
Replaced bool `db:"replaced"`
}

// addReplacementOrder inserts or updates the replacementOrders row matching the
// provided serial with the details provided. This function accepts a
// transaction so that the insert or update takes place within the new order
// transaction.
func addReplacementOrder(ctx context.Context, db db.SelectExecer, serial string, orderID int64, orderExpires time.Time) error {
var existingID []int64
_, err := db.Select(ctx, &existingID, `
SELECT id
FROM replacementOrders
WHERE serial = ?
LIMIT 1`,
serial,
)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("checking for existing replacement order: %w", err)
}

if len(existingID) > 0 {
// Update existing replacementOrder row.
_, err = db.ExecContext(ctx, `
UPDATE replacementOrders
SET orderID = ?, orderExpires = ?
WHERE id = ?`,
orderID, orderExpires,
existingID[0],
)
if err != nil {
return fmt.Errorf("updating replacement order: %w", err)
}
} else {
// Insert new replacementOrder row.
_, err = db.ExecContext(ctx, `
INSERT INTO replacementOrders (serial, orderID, orderExpires)
VALUES (?, ?, ?)`,
serial, orderID, orderExpires,
)
if err != nil {
return fmt.Errorf("creating replacement order: %w", err)
}
}
return nil
}

// setReplacementOrderFinalized sets the replaced flag for the replacementOrder
// row matching the provided orderID to true. This function accepts a
// transaction so that the update can take place within the finalization
// transaction.
func setReplacementOrderFinalized(ctx context.Context, db db.Execer, orderID int64) error {
_, err := db.ExecContext(ctx, `
UPDATE replacementOrders
SET replaced = true
WHERE orderID = ?
LIMIT 1`,
orderID,
)
if err != nil {
return err
}
return nil
}
103 changes: 103 additions & 0 deletions sa/model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ import (
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"database/sql"
"encoding/base64"
"fmt"
"math/big"
"net"
"os"
"testing"
"time"

"github.com/jmhodges/clock"
"github.com/letsencrypt/boulder/db"
"github.com/letsencrypt/boulder/features"
"github.com/letsencrypt/boulder/grpc"
"github.com/letsencrypt/boulder/probs"
"github.com/letsencrypt/boulder/test/vars"
Expand Down Expand Up @@ -413,3 +416,103 @@ func TestIncidentSerialModel(t *testing.T) {
test.AssertEquals(t, *res2.OrderID, int64(2))
test.AssertEquals(t, *res2.LastNoticeSent, time.Date(2023, 06, 29, 16, 9, 00, 00, time.UTC))
}

func TestAddReplacementOrder(t *testing.T) {
if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" {
t.Skip("Test requires replacementOrders database table")
}

sa, _, cleanUp := initSA(t)
defer cleanUp()

features.Set(features.Config{TrackReplacementCertificatesARI: true})
defer features.Reset()

oldCertSerial := "1234567890"
orderId := int64(1337)
orderExpires := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second)

// Add a replacement order which doesn't exist.
err := addReplacementOrder(ctx, sa.dbMap, oldCertSerial, orderId, orderExpires)
test.AssertNotError(t, err, "addReplacementOrder failed")

// Fetch the replacement order so we can ensure it was added.
var replacementRow replacementOrderModel
err = sa.dbReadOnlyMap.SelectOne(
ctx,
&replacementRow,
"SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1",
oldCertSerial,
)
test.AssertNotError(t, err, "SELECT from replacementOrders failed")
test.AssertEquals(t, oldCertSerial, replacementRow.Serial)
test.AssertEquals(t, orderId, replacementRow.OrderID)
test.AssertEquals(t, orderExpires, replacementRow.OrderExpires)

nextOrderId := int64(1338)
nextOrderExpires := time.Now().Add(48 * time.Hour).UTC().Truncate(time.Second)

// Add a replacement order which already exists.
err = addReplacementOrder(ctx, sa.dbMap, oldCertSerial, nextOrderId, nextOrderExpires)
test.AssertNotError(t, err, "addReplacementOrder failed")

// Fetch the replacement order so we can ensure it was updated.
err = sa.dbReadOnlyMap.SelectOne(
ctx,
&replacementRow,
"SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1",
oldCertSerial,
)
test.AssertNotError(t, err, "SELECT from replacementOrders failed")
test.AssertEquals(t, oldCertSerial, replacementRow.Serial)
test.AssertEquals(t, nextOrderId, replacementRow.OrderID)
test.AssertEquals(t, nextOrderExpires, replacementRow.OrderExpires)
}

func TestSetReplacementOrderFinalized(t *testing.T) {
if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" {
t.Skip("Test requires replacementOrders database table")
}

sa, _, cleanUp := initSA(t)
defer cleanUp()

features.Set(features.Config{TrackReplacementCertificatesARI: true})
defer features.Reset()

oldCertSerial := "1234567890"
orderId := int64(1337)
orderExpires := time.Now().Add(24 * time.Hour).UTC().Truncate(time.Second)

// Mark a non-existent certificate as finalized/replaced.
err := setReplacementOrderFinalized(ctx, sa.dbMap, orderId)
test.AssertNotError(t, err, "setReplacementOrderFinalized failed")

// Ensure no replacement order was added for some reason.
var replacementRow replacementOrderModel
err = sa.dbReadOnlyMap.SelectOne(
ctx,
&replacementRow,
"SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1",
oldCertSerial,
)
test.AssertErrorIs(t, err, sql.ErrNoRows)

// Add a replacement order.
err = addReplacementOrder(ctx, sa.dbMap, oldCertSerial, orderId, orderExpires)
test.AssertNotError(t, err, "addReplacementOrder failed")

// Mark the certificate as finalized/replaced.
err = setReplacementOrderFinalized(ctx, sa.dbMap, orderId)
test.AssertNotError(t, err, "setReplacementOrderFinalized failed")

// Fetch the replacement order so we can ensure it was finalized.
err = sa.dbReadOnlyMap.SelectOne(
ctx,
&replacementRow,
"SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1",
oldCertSerial,
)
test.AssertNotError(t, err, "SELECT from replacementOrders failed")
test.Assert(t, replacementRow.Replaced, "replacement order should be marked as finalized")
}
Loading

0 comments on commit f10abd2

Please sign in to comment.