SA/ARI: Add method of tracking certificate replacement (#7284)

Part of #6732
Part of #7038
This commit is contained in:
Samantha 2024-02-08 14:19:29 -05:00 committed by GitHub
parent 8d7e84b013
commit f10abd27eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1069 additions and 560 deletions

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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`;

View File

@ -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';
@ -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';

View File

@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
@ -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
}

View File

@ -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"
@ -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")
}

File diff suppressed because it is too large Load Diff

View File

@ -38,6 +38,7 @@ service StorageAuthorityReadOnly {
rpc IncidentsForSerial(Serial) returns (Incidents) {}
rpc KeyBlocked(KeyBlockedRequest) returns (Exists) {}
rpc PreviousCertificateExists(PreviousCertificateExistsRequest) returns (Exists) {}
rpc ReplacementOrderExists(Serial) returns (Exists) {}
rpc SerialsForIncident (SerialsForIncidentRequest) returns (stream IncidentSerial) {}
}
@ -72,6 +73,7 @@ service StorageAuthority {
rpc IncidentsForSerial(Serial) returns (Incidents) {}
rpc KeyBlocked(KeyBlockedRequest) returns (Exists) {}
rpc PreviousCertificateExists(PreviousCertificateExistsRequest) returns (Exists) {}
rpc ReplacementOrderExists(Serial) returns (Exists) {}
rpc SerialsForIncident (SerialsForIncidentRequest) returns (stream IncidentSerial) {}
// Adders
rpc AddBlockedKey(AddBlockedKeyRequest) returns (google.protobuf.Empty) {}
@ -253,12 +255,13 @@ message OrderRequest {
}
message NewOrderRequest {
// Next unused field number: 6
// Next unused field number: 7
int64 registrationID = 1;
reserved 2; // Previously expiresNS
google.protobuf.Timestamp expires = 5;
repeated string names = 3;
repeated int64 v2Authorizations = 4;
string replacesSerial = 6;
}
message NewOrderAndAuthzsRequest {

View File

@ -53,6 +53,7 @@ type StorageAuthorityReadOnlyClient interface {
IncidentsForSerial(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Incidents, error)
KeyBlocked(ctx context.Context, in *KeyBlockedRequest, opts ...grpc.CallOption) (*Exists, error)
PreviousCertificateExists(ctx context.Context, in *PreviousCertificateExistsRequest, opts ...grpc.CallOption) (*Exists, error)
ReplacementOrderExists(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Exists, error)
SerialsForIncident(ctx context.Context, in *SerialsForIncidentRequest, opts ...grpc.CallOption) (StorageAuthorityReadOnly_SerialsForIncidentClient, error)
}
@ -339,6 +340,15 @@ func (c *storageAuthorityReadOnlyClient) PreviousCertificateExists(ctx context.C
return out, nil
}
func (c *storageAuthorityReadOnlyClient) ReplacementOrderExists(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Exists, error) {
out := new(Exists)
err := c.cc.Invoke(ctx, "/sa.StorageAuthorityReadOnly/ReplacementOrderExists", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *storageAuthorityReadOnlyClient) SerialsForIncident(ctx context.Context, in *SerialsForIncidentRequest, opts ...grpc.CallOption) (StorageAuthorityReadOnly_SerialsForIncidentClient, error) {
stream, err := c.cc.NewStream(ctx, &StorageAuthorityReadOnly_ServiceDesc.Streams[1], "/sa.StorageAuthorityReadOnly/SerialsForIncident", opts...)
if err != nil {
@ -403,6 +413,7 @@ type StorageAuthorityReadOnlyServer interface {
IncidentsForSerial(context.Context, *Serial) (*Incidents, error)
KeyBlocked(context.Context, *KeyBlockedRequest) (*Exists, error)
PreviousCertificateExists(context.Context, *PreviousCertificateExistsRequest) (*Exists, error)
ReplacementOrderExists(context.Context, *Serial) (*Exists, error)
SerialsForIncident(*SerialsForIncidentRequest, StorageAuthorityReadOnly_SerialsForIncidentServer) error
mustEmbedUnimplementedStorageAuthorityReadOnlyServer()
}
@ -495,6 +506,9 @@ func (UnimplementedStorageAuthorityReadOnlyServer) KeyBlocked(context.Context, *
func (UnimplementedStorageAuthorityReadOnlyServer) PreviousCertificateExists(context.Context, *PreviousCertificateExistsRequest) (*Exists, error) {
return nil, status.Errorf(codes.Unimplemented, "method PreviousCertificateExists not implemented")
}
func (UnimplementedStorageAuthorityReadOnlyServer) ReplacementOrderExists(context.Context, *Serial) (*Exists, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReplacementOrderExists not implemented")
}
func (UnimplementedStorageAuthorityReadOnlyServer) SerialsForIncident(*SerialsForIncidentRequest, StorageAuthorityReadOnly_SerialsForIncidentServer) error {
return status.Errorf(codes.Unimplemented, "method SerialsForIncident not implemented")
}
@ -1019,6 +1033,24 @@ func _StorageAuthorityReadOnly_PreviousCertificateExists_Handler(srv interface{}
return interceptor(ctx, in, info, handler)
}
func _StorageAuthorityReadOnly_ReplacementOrderExists_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Serial)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StorageAuthorityReadOnlyServer).ReplacementOrderExists(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/sa.StorageAuthorityReadOnly/ReplacementOrderExists",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StorageAuthorityReadOnlyServer).ReplacementOrderExists(ctx, req.(*Serial))
}
return interceptor(ctx, in, info, handler)
}
func _StorageAuthorityReadOnly_SerialsForIncident_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(SerialsForIncidentRequest)
if err := stream.RecvMsg(m); err != nil {
@ -1155,6 +1187,10 @@ var StorageAuthorityReadOnly_ServiceDesc = grpc.ServiceDesc{
MethodName: "PreviousCertificateExists",
Handler: _StorageAuthorityReadOnly_PreviousCertificateExists_Handler,
},
{
MethodName: "ReplacementOrderExists",
Handler: _StorageAuthorityReadOnly_ReplacementOrderExists_Handler,
},
},
Streams: []grpc.StreamDesc{
{
@ -1204,6 +1240,7 @@ type StorageAuthorityClient interface {
IncidentsForSerial(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Incidents, error)
KeyBlocked(ctx context.Context, in *KeyBlockedRequest, opts ...grpc.CallOption) (*Exists, error)
PreviousCertificateExists(ctx context.Context, in *PreviousCertificateExistsRequest, opts ...grpc.CallOption) (*Exists, error)
ReplacementOrderExists(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Exists, error)
SerialsForIncident(ctx context.Context, in *SerialsForIncidentRequest, opts ...grpc.CallOption) (StorageAuthority_SerialsForIncidentClient, error)
// Adders
AddBlockedKey(ctx context.Context, in *AddBlockedKeyRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
@ -1509,6 +1546,15 @@ func (c *storageAuthorityClient) PreviousCertificateExists(ctx context.Context,
return out, nil
}
func (c *storageAuthorityClient) ReplacementOrderExists(ctx context.Context, in *Serial, opts ...grpc.CallOption) (*Exists, error) {
out := new(Exists)
err := c.cc.Invoke(ctx, "/sa.StorageAuthority/ReplacementOrderExists", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *storageAuthorityClient) SerialsForIncident(ctx context.Context, in *SerialsForIncidentRequest, opts ...grpc.CallOption) (StorageAuthority_SerialsForIncidentClient, error) {
stream, err := c.cc.NewStream(ctx, &StorageAuthority_ServiceDesc.Streams[1], "/sa.StorageAuthority/SerialsForIncident", opts...)
if err != nil {
@ -1736,6 +1782,7 @@ type StorageAuthorityServer interface {
IncidentsForSerial(context.Context, *Serial) (*Incidents, error)
KeyBlocked(context.Context, *KeyBlockedRequest) (*Exists, error)
PreviousCertificateExists(context.Context, *PreviousCertificateExistsRequest) (*Exists, error)
ReplacementOrderExists(context.Context, *Serial) (*Exists, error)
SerialsForIncident(*SerialsForIncidentRequest, StorageAuthority_SerialsForIncidentServer) error
// Adders
AddBlockedKey(context.Context, *AddBlockedKeyRequest) (*emptypb.Empty, error)
@ -1847,6 +1894,9 @@ func (UnimplementedStorageAuthorityServer) KeyBlocked(context.Context, *KeyBlock
func (UnimplementedStorageAuthorityServer) PreviousCertificateExists(context.Context, *PreviousCertificateExistsRequest) (*Exists, error) {
return nil, status.Errorf(codes.Unimplemented, "method PreviousCertificateExists not implemented")
}
func (UnimplementedStorageAuthorityServer) ReplacementOrderExists(context.Context, *Serial) (*Exists, error) {
return nil, status.Errorf(codes.Unimplemented, "method ReplacementOrderExists not implemented")
}
func (UnimplementedStorageAuthorityServer) SerialsForIncident(*SerialsForIncidentRequest, StorageAuthority_SerialsForIncidentServer) error {
return status.Errorf(codes.Unimplemented, "method SerialsForIncident not implemented")
}
@ -2424,6 +2474,24 @@ func _StorageAuthority_PreviousCertificateExists_Handler(srv interface{}, ctx co
return interceptor(ctx, in, info, handler)
}
func _StorageAuthority_ReplacementOrderExists_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(Serial)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StorageAuthorityServer).ReplacementOrderExists(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/sa.StorageAuthority/ReplacementOrderExists",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StorageAuthorityServer).ReplacementOrderExists(ctx, req.(*Serial))
}
return interceptor(ctx, in, info, handler)
}
func _StorageAuthority_SerialsForIncident_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(SerialsForIncidentRequest)
if err := stream.RecvMsg(m); err != nil {
@ -2884,6 +2952,10 @@ var StorageAuthority_ServiceDesc = grpc.ServiceDesc{
MethodName: "PreviousCertificateExists",
Handler: _StorageAuthority_PreviousCertificateExists_Handler,
},
{
MethodName: "ReplacementOrderExists",
Handler: _StorageAuthority_ReplacementOrderExists_Handler,
},
{
MethodName: "AddBlockedKey",
Handler: _StorageAuthority_AddBlockedKey_Handler,

View File

@ -19,6 +19,7 @@ import (
corepb "github.com/letsencrypt/boulder/core/proto"
"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/revocation"
@ -566,6 +567,15 @@ func (ssa *SQLStorageAuthority) NewOrderAndAuthzs(ctx context.Context, req *sapb
BeganProcessing: false,
}
if features.Get().TrackReplacementCertificatesARI && req.NewOrder.ReplacesSerial != "" {
// Update the replacementOrders table to indicate that this order
// replaces the provided certificate serial.
err := addReplacementOrder(ctx, tx, req.NewOrder.ReplacesSerial, order.ID, order.Expires)
if err != nil {
return nil, err
}
}
// Calculate the order status before returning it. Since it may have reused
// all valid authorizations the order may be "born" in a ready status.
status, err := statusForOrder(ctx, tx, res, ssa.clk.Now())
@ -696,6 +706,13 @@ func (ssa *SQLStorageAuthority) FinalizeOrder(ctx context.Context, req *sapb.Fin
return nil, err
}
if features.Get().TrackReplacementCertificatesARI {
err = setReplacementOrderFinalized(ctx, tx, req.Id)
if err != nil {
return nil, err
}
}
return nil, nil
})
if overallError != nil {

View File

@ -4004,3 +4004,112 @@ func TestUpdateCRLShard(t *testing.T) {
)
test.AssertError(t, err, "updating an unknown shard")
}
func TestReplacementOrderExists(t *testing.T) {
if os.Getenv("BOULDER_CONFIG_DIR") != "test/config-next" {
t.Skip("Test requires replacementOrders database table")
}
sa, fc, cleanUp := initSA(t)
defer cleanUp()
features.Set(features.Config{TrackReplacementCertificatesARI: true})
defer features.Reset()
oldCertSerial := "1234567890"
// Check that a non-existent replacement order does not exist.
exists, err := sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: oldCertSerial})
test.AssertNotError(t, err, "failed to check for replacement order")
test.Assert(t, !exists.Exists, "replacement for non-existent serial should not exist")
// Create a test registration to reference.
reg := createWorkingRegistration(t, sa)
// Add one valid authz.
expires := fc.Now().Add(time.Hour)
attemptedAt := fc.Now()
authzID := createFinalizedAuthorization(t, sa, "example.com", expires, "valid", attemptedAt)
// Add a new order in pending status with no certificate serial.
expires1Year := sa.clk.Now().Add(365 * 24 * time.Hour)
order, err := sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{
NewOrder: &sapb.NewOrderRequest{
RegistrationID: reg.Id,
Expires: timestamppb.New(expires1Year),
Names: []string{"example.com"},
V2Authorizations: []int64{authzID},
},
})
test.AssertNotError(t, err, "NewOrderAndAuthzs failed")
// Set the order to processing so it can be finalized
_, err = sa.SetOrderProcessing(ctx, &sapb.OrderRequest{Id: order.Id})
test.AssertNotError(t, err, "SetOrderProcessing failed")
// Finalize the order with a certificate oldCertSerial.
order.CertificateSerial = oldCertSerial
_, err = sa.FinalizeOrder(ctx, &sapb.FinalizeOrderRequest{Id: order.Id, CertificateSerial: order.CertificateSerial})
test.AssertNotError(t, err, "FinalizeOrder failed")
// Create a replacement order.
order, err = sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{
NewOrder: &sapb.NewOrderRequest{
RegistrationID: reg.Id,
Expires: timestamppb.New(expires1Year),
Names: []string{"example.com"},
V2Authorizations: []int64{authzID},
ReplacesSerial: oldCertSerial,
},
})
test.AssertNotError(t, err, "NewOrderAndAuthzs failed")
// Check that a pending replacement order exists.
exists, err = sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: oldCertSerial})
test.AssertNotError(t, err, "failed to check for replacement order")
test.Assert(t, exists.Exists, "replacement order should exist")
// Set the order to processing so it can be finalized.
_, err = sa.SetOrderProcessing(ctx, &sapb.OrderRequest{Id: order.Id})
test.AssertNotError(t, err, "SetOrderProcessing failed")
// Check that a replacement order in processing still exists.
exists, err = sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: oldCertSerial})
test.AssertNotError(t, err, "failed to check for replacement order")
test.Assert(t, exists.Exists, "replacement order in processing should still exist")
order.CertificateSerial = "0123456789"
_, err = sa.FinalizeOrder(ctx, &sapb.FinalizeOrderRequest{Id: order.Id, CertificateSerial: order.CertificateSerial})
test.AssertNotError(t, err, "FinalizeOrder failed")
// Check that a finalized replacement order still exists.
exists, err = sa.ReplacementOrderExists(ctx, &sapb.Serial{Serial: oldCertSerial})
test.AssertNotError(t, err, "failed to check for replacement order")
test.Assert(t, exists.Exists, "replacement order in processing should still exist")
// Try updating the replacement order.
// Create a replacement order.
newReplacementOrder, err := sa.NewOrderAndAuthzs(ctx, &sapb.NewOrderAndAuthzsRequest{
NewOrder: &sapb.NewOrderRequest{
RegistrationID: reg.Id,
Expires: timestamppb.New(expires1Year),
Names: []string{"example.com"},
V2Authorizations: []int64{authzID},
ReplacesSerial: oldCertSerial,
},
})
test.AssertNotError(t, err, "NewOrderAndAuthzs failed")
// Fetch the replacement order so we can ensure it was updated.
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, newReplacementOrder.Id, replacementRow.OrderID)
test.AssertEquals(t, newReplacementOrder.Expires.AsTime(), replacementRow.OrderExpires)
}

View File

@ -1502,3 +1502,66 @@ func (ssa *SQLStorageAuthorityRO) Health(ctx context.Context) error {
}
return nil
}
// ReplacementOrderExists returns whether a valid replacement order exists for
// the given certificate serial number. An existing but expired or otherwise
// invalid replacement order is not considered to exist.
func (ssa *SQLStorageAuthorityRO) ReplacementOrderExists(ctx context.Context, req *sapb.Serial) (*sapb.Exists, error) {
if req == nil || req.Serial == "" {
return nil, errIncompleteRequest
}
var replacement replacementOrderModel
err := ssa.dbReadOnlyMap.SelectOne(
ctx,
&replacement,
"SELECT * FROM replacementOrders WHERE serial = ? LIMIT 1",
req.Serial,
)
if err != nil {
if db.IsNoRows(err) {
// No replacement order exists.
return &sapb.Exists{Exists: false}, nil
}
return nil, err
}
if replacement.Replaced {
// Certificate has already been replaced.
return &sapb.Exists{Exists: true}, nil
}
if replacement.OrderExpires.Before(ssa.clk.Now()) {
// The existing replacement order has expired.
return &sapb.Exists{Exists: false}, nil
}
// Pull the replacement order so we can inspect its status.
replacementOrder, err := ssa.GetOrder(ctx, &sapb.OrderRequest{Id: replacement.OrderID})
if err != nil {
if errors.Is(err, berrors.NotFound) {
// The existing replacement order has been deleted. This should
// never happen.
ssa.log.Errf("replacement order %d for serial %q not found", replacement.OrderID, req.Serial)
return &sapb.Exists{Exists: false}, nil
}
}
switch replacementOrder.Status {
case string(core.StatusPending), string(core.StatusReady), string(core.StatusProcessing), string(core.StatusValid):
// An existing replacement order is either still being worked on or has
// already been finalized.
return &sapb.Exists{Exists: true}, nil
case string(core.StatusInvalid):
// The existing replacement order cannot be finalized. The requester
// should create a new replacement order.
return &sapb.Exists{Exists: false}, nil
default:
// Replacement order is in an unknown state. This should never happen.
return nil, fmt.Errorf("unknown replacement order status: %q", replacementOrder.Status)
}
}
func (ssa *SQLStorageAuthority) ReplacementOrderExists(ctx context.Context, req *sapb.Serial) (*sapb.Exists, error) {
return ssa.SQLStorageAuthorityRO.ReplacementOrderExists(ctx, req)
}

View File

@ -47,7 +47,9 @@
}
},
"healthCheckInterval": "4s",
"features": {}
"features": {
"TrackReplacementCertificatesARI": true
}
},
"syslog": {
"stdoutlevel": 6,